From 6b7f7b35e87bbefa1da214dd4646a8188f4d2846 Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Fri, 20 Feb 2026 21:20:14 +0545 Subject: [PATCH 01/10] feat: rebuild as framework-agnostic EsewaPayment SDK --- .github/workflows/ci.yml | 64 ++++++ .gitignore | 12 +- .travis.yml | 30 --- README.md | 118 ++++-------- composer.json | 96 ++++----- demo.php | 39 ---- grumphp.yml | 15 -- phpstan.neon | 7 + phpunit.xml.dist | 26 +-- rector.php | 21 ++ src/Client/CallbackService.php | 20 ++ src/Client/CheckoutService.php | 47 +++++ src/Client/EsewaGateway.php | 45 +++++ src/Client/TransactionService.php | 38 ++++ src/Config/Config.php | 37 ++++ src/Config/EndpointResolver.php | 32 +++ src/Config/Environment.php | 20 ++ src/Contracts/TransportInterface.php | 15 ++ src/Domain/Checkout/CheckoutIntent.php | 27 +++ src/Domain/Checkout/CheckoutRequest.php | 41 ++++ src/Domain/Transaction/PaymentStatus.php | 22 +++ src/Domain/Transaction/StatusQuery.php | 18 ++ src/Domain/Transaction/StatusResult.php | 22 +++ src/Domain/Verification/ReturnPayload.php | 44 +++++ .../Verification/VerificationContext.php | 15 ++ .../Verification/VerificationResult.php | 26 +++ src/EsewaPayment.php | 17 ++ src/Exception/ApiErrorException.php | 7 + src/Exception/EsewaException.php | 7 + src/Exception/FraudValidationException.php | 7 + src/Exception/InvalidCallbackException.php | 7 + src/Exception/InvalidPayloadException.php | 7 + src/Exception/SignatureException.php | 7 + src/Exception/TransportException.php | 7 + .../Transport/Psr18Transport.php | 51 +++++ src/Message/AbstractRequest.php | 151 --------------- src/Message/PurchaseRequest.php | 56 ------ src/Message/PurchaseResponse.php | 70 ------- src/Message/VerifyPaymentRequest.php | 80 -------- src/Message/VerifyPaymentResponse.php | 40 ---- src/SecureGateway.php | 182 ------------------ src/Service/CallbackVerifier.php | 70 +++++++ src/Service/SignatureService.php | 53 +++++ tests/Fakes/FakeTransport.php | 37 ++++ tests/Message/PurchaseRequestTest.php | 44 ----- tests/Message/PurchaseResponseTest.php | 25 --- tests/Message/VerifyPaymentRequestTest.php | 52 ----- tests/Mock/VerifyPaymentRequestFailure.txt | 7 - tests/Mock/VerifyPaymentRequestSuccess.txt | 7 - tests/SecureGatewayTest.php | 57 ------ tests/Unit/CallbackServiceTest.php | 86 +++++++++ tests/Unit/CheckoutServiceTest.php | 44 +++++ tests/Unit/SignatureServiceTest.php | 22 +++ tests/Unit/TransactionServiceTest.php | 42 ++++ 54 files changed, 1140 insertions(+), 999 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml delete mode 100644 demo.php delete mode 100644 grumphp.yml create mode 100644 phpstan.neon create mode 100644 rector.php create mode 100644 src/Client/CallbackService.php create mode 100644 src/Client/CheckoutService.php create mode 100644 src/Client/EsewaGateway.php create mode 100644 src/Client/TransactionService.php create mode 100644 src/Config/Config.php create mode 100644 src/Config/EndpointResolver.php create mode 100644 src/Config/Environment.php create mode 100644 src/Contracts/TransportInterface.php create mode 100644 src/Domain/Checkout/CheckoutIntent.php create mode 100644 src/Domain/Checkout/CheckoutRequest.php create mode 100644 src/Domain/Transaction/PaymentStatus.php create mode 100644 src/Domain/Transaction/StatusQuery.php create mode 100644 src/Domain/Transaction/StatusResult.php create mode 100644 src/Domain/Verification/ReturnPayload.php create mode 100644 src/Domain/Verification/VerificationContext.php create mode 100644 src/Domain/Verification/VerificationResult.php create mode 100644 src/EsewaPayment.php create mode 100644 src/Exception/ApiErrorException.php create mode 100644 src/Exception/EsewaException.php create mode 100644 src/Exception/FraudValidationException.php create mode 100644 src/Exception/InvalidCallbackException.php create mode 100644 src/Exception/InvalidPayloadException.php create mode 100644 src/Exception/SignatureException.php create mode 100644 src/Exception/TransportException.php create mode 100644 src/Infrastructure/Transport/Psr18Transport.php delete mode 100644 src/Message/AbstractRequest.php delete mode 100644 src/Message/PurchaseRequest.php delete mode 100644 src/Message/PurchaseResponse.php delete mode 100644 src/Message/VerifyPaymentRequest.php delete mode 100644 src/Message/VerifyPaymentResponse.php delete mode 100644 src/SecureGateway.php create mode 100644 src/Service/CallbackVerifier.php create mode 100644 src/Service/SignatureService.php create mode 100644 tests/Fakes/FakeTransport.php delete mode 100644 tests/Message/PurchaseRequestTest.php delete mode 100644 tests/Message/PurchaseResponseTest.php delete mode 100644 tests/Message/VerifyPaymentRequestTest.php delete mode 100644 tests/Mock/VerifyPaymentRequestFailure.txt delete mode 100644 tests/Mock/VerifyPaymentRequestSuccess.txt delete mode 100644 tests/SecureGatewayTest.php create mode 100644 tests/Unit/CallbackServiceTest.php create mode 100644 tests/Unit/CheckoutServiceTest.php create mode 100644 tests/Unit/SignatureServiceTest.php create mode 100644 tests/Unit/TransactionServiceTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f05dac3 --- /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.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/.gitignore b/.gitignore index 57d40ed..e0b9a47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ -/vendor +/vendor/ +/bin/ composer.lock composer.phar -phpunit.xml -php-cs-fixer.phar -phpcbf.phar -phpcs.phar +.phpunit.cache/ +.phpunit.result.cache +coverage/ +*.cache +.DS_Store diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 54f5dd8..0000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: php - -php: - - 5.6 - - 7.0 - - 7.1 - - 7.2 - - 7.3 - - 7.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 5270e49..9701fb9 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,60 @@ -# Omnipay: eSewa +# EsewaPayment PHP SDK -**eSewa driver for the Omnipay PHP payment processing library** +A modern, framework-agnostic eSewa ePay v2 SDK for PHP. -[Omnipay](https://github.com/thephpleague/omnipay) is a framework agnostic, multi-gateway payment -processing library for PHP. This package implements eSewa support for Omnipay. +## Highlights -[![StyleCI](https://github.styleci.io/repos/75586885/shield?branch=master&format=plastic)](https://github.styleci.io/repos/75586885) -[![Build Status](https://travis-ci.org/sudiptpa/esewa.svg?branch=master)](https://travis-ci.org/sudiptpa/esewa) -[![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) +- ePay v2 checkout intent generation +- HMAC-SHA256 + base64 signature handling +- Callback/return payload verification +- Status check API client +- Anti-fraud field consistency checks +- PSR-18 transport architecture +- PHP 8.3 to 8.5 support ## 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 +```bash +composer require sudiptpa/esewa-payment ``` -## Basic Usage - -### Purchase - -```php - use Omnipay\Omnipay; - use Exception; +Optional adapters used in examples: - $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(); - } +```bash +composer require symfony/http-client nyholm/psr7 ``` -### Verify Payment +## Quick Start ```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 +use EsewaPayment\Client\EsewaGateway; +use EsewaPayment\Config\Config; +use EsewaPayment\Infrastructure\Transport\Psr18Transport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\HttpClient\Psr18Client; + +$config = Config::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'YOUR_SECRET', + 'environment' => 'uat', +]); + +$gateway = new EsewaGateway( + $config, + new Psr18Transport(new Psr18Client(), new Psr17Factory()) +); ``` -## Laravel Integration - -Please follow the [eSewa Online Payment Gateway Integration](https://sujipthapa.co/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. +## Modules -## Contributing +- `checkout()->createIntent(...)` +- `callback()->verify(...)` +- `transactions()->status(...)` -Contributions are **welcome** and will be fully **credited**. +## Development -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. +```bash +composer test +composer stan +composer rector:check +``` diff --git a/composer.json b/composer.json index 75a416c..e0b55d9 100644 --- a/composer.json +++ b/composer.json @@ -1,45 +1,55 @@ { - "name":"sudiptpa/omnipay-esewa", - "type":"library", - "description":"eSewa API driver for the Omnipay payment processing library.", - "keywords":[ - "gateway", - "merchant", - "omnipay", - "pay", - "payment", - "esewa" - ], - "homepage":"https://github.com/sudiptpa/esewa", - "license":"MIT", - "authors":[ - { - "name":"Sujip Thapa", - "email":"sudiptpa@gmail.com" - } - ], - "autoload":{ - "psr-4":{ - "Omnipay\\Esewa\\":"src/" - } - }, - "require":{ - "omnipay/common":"^3" - }, - "require-dev":{ - "omnipay/tests":"^3", - "squizlabs/php_codesniffer":"^3", - "phpro/grumphp":"^0.14.0" - }, - "extra":{ - "branch-alias":{ - "dev-master":"1.0.x-dev" - } - }, - "scripts":{ - "test":"vendor/bin/phpunit", - "check-style":"phpcs -p --standard=PSR2 src/", - "fix-style":"phpcbf -p --standard=PSR2 src/" - }, - "prefer-stable":true + "name": "sudiptpa/esewa-payment", + "type": "library", + "description": "Framework-agnostic eSewa ePay v2 payment SDK for PHP.", + "keywords": [ + "esewa", + "epay", + "payment", + "gateway", + "nepal" + ], + "license": "MIT", + "authors": [ + { + "name": "Sujip Thapa", + "email": "sudiptpa@gmail.com" + } + ], + "require": { + "php": ">=8.3 <8.6", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^1.11", + "rector/rector": "^1.2", + "symfony/http-client": "^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": "2.0.x-dev" + } + }, + "scripts": { + "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" + }, + "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/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 eacfa5c..e8ed43a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,22 +1,8 @@ - - - - ./tests/ - - - - - ./src - - + + + + tests + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..ae96a82 --- /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..2a88d16 --- /dev/null +++ b/src/Client/CallbackService.php @@ -0,0 +1,20 @@ +verifier->verify($payload, $context); + } +} diff --git a/src/Client/CheckoutService.php b/src/Client/CheckoutService.php new file mode 100644 index 0000000..aeba40c --- /dev/null +++ b/src/Client/CheckoutService.php @@ -0,0 +1,47 @@ +totalAmount(); + $signature = $this->signatures->generate( + $totalAmount, + $request->transactionUuid, + $request->productCode, + $request->signedFieldNames, + ); + + $fields = [ + 'amount' => number_format((float)$request->amount, 2, '.', ''), + 'tax_amount' => number_format((float)$request->taxAmount, 2, '.', ''), + 'product_service_charge' => number_format((float)$request->serviceCharge, 2, '.', ''), + 'product_delivery_charge' => number_format((float)$request->deliveryCharge, 2, '.', ''), + 'total_amount' => $totalAmount, + 'transaction_uuid' => $request->transactionUuid, + 'product_code' => $request->productCode, + 'success_url' => $request->successUrl, + 'failure_url' => $request->failureUrl, + 'signed_field_names' => $request->signedFieldNames, + 'signature' => $signature, + ]; + + return new CheckoutIntent($this->endpoints->checkoutFormUrl($this->config), $fields); + } +} diff --git a/src/Client/EsewaGateway.php b/src/Client/EsewaGateway.php new file mode 100644 index 0000000..1996fd7 --- /dev/null +++ b/src/Client/EsewaGateway.php @@ -0,0 +1,45 @@ +secretKey); + + $this->checkout = new CheckoutService($config, $endpoints, $signatures); + $this->callback = new CallbackService(new CallbackVerifier($signatures)); + $this->transactions = new TransactionService($config, $endpoints, $transport); + } + + public function checkout(): CheckoutService + { + return $this->checkout; + } + + public function callback(): 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..e30c403 --- /dev/null +++ b/src/Client/TransactionService.php @@ -0,0 +1,38 @@ +transport->get( + $this->endpoints->statusCheckUrl($this->config), + [ + 'product_code' => $query->productCode, + 'total_amount' => $query->totalAmount, + 'transaction_uuid' => $query->transactionUuid, + ] + ); + + $status = PaymentStatus::fromValue(isset($payload['status']) ? (string)$payload['status'] : null); + $referenceId = isset($payload['ref_id']) ? (string)$payload['ref_id'] : null; + + return new StatusResult($status, $referenceId, $payload); + } +} diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..53a9509 --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,37 @@ + $config */ + public static function fromArray(array $config): self + { + return new self( + merchantCode: (string)($config['merchant_code'] ?? ''), + secretKey: (string)($config['secret_key'] ?? ''), + environment: Environment::fromString((string)($config['environment'] ?? 'uat')), + checkoutFormUrl: $config['checkout_form_url'] ?? null, + statusCheckUrl: $config['status_check_url'] ?? null, + ); + } +} diff --git a/src/Config/EndpointResolver.php b/src/Config/EndpointResolver.php new file mode 100644 index 0000000..a4d609d --- /dev/null +++ b/src/Config/EndpointResolver.php @@ -0,0 +1,32 @@ +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(Config $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/Contracts/TransportInterface.php b/src/Contracts/TransportInterface.php new file mode 100644 index 0000000..208f115 --- /dev/null +++ b/src/Contracts/TransportInterface.php @@ -0,0 +1,15 @@ + $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..3a5c6f1 --- /dev/null +++ b/src/Domain/Checkout/CheckoutIntent.php @@ -0,0 +1,27 @@ + $fields + */ + public function __construct( + public readonly string $actionUrl, + public readonly array $fields, + ) {} + + /** + * @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/CheckoutRequest.php b/src/Domain/Checkout/CheckoutRequest.php new file mode 100644 index 0000000..c9fbb7b --- /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..c77175d --- /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/Verification/ReturnPayload.php b/src/Domain/Verification/ReturnPayload.php new file mode 100644 index 0000000..209c280 --- /dev/null +++ b/src/Domain/Verification/ReturnPayload.php @@ -0,0 +1,44 @@ + $payload */ + public static function fromArray(array $payload): self + { + return new self( + data: (string)($payload['data'] ?? ''), + signature: (string)($payload['signature'] ?? ''), + ); + } + + /** @return array */ + public function decodedData(): array + { + $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 $json; + } +} diff --git a/src/Domain/Verification/VerificationContext.php b/src/Domain/Verification/VerificationContext.php new file mode 100644 index 0000000..7a354a1 --- /dev/null +++ b/src/Domain/Verification/VerificationContext.php @@ -0,0 +1,15 @@ + $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/EsewaPayment.php b/src/EsewaPayment.php new file mode 100644 index 0000000..27416c4 --- /dev/null +++ b/src/EsewaPayment.php @@ -0,0 +1,17 @@ + $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 1790d4c..0000000 --- a/src/Message/AbstractRequest.php +++ /dev/null @@ -1,151 +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); - } -} diff --git a/src/Message/PurchaseRequest.php b/src/Message/PurchaseRequest.php deleted file mode 100644 index fb98a06..0000000 --- a/src/Message/PurchaseRequest.php +++ /dev/null @@ -1,56 +0,0 @@ -validate('merchantCode', 'amount', 'totalAmount', 'productCode', 'failedUrl', 'returnUrl'); - - return [ - 'amt' => $this->getAmount(), - 'pdc' => $this->getDeliveryCharge() ?: 0, - 'psc' => $this->getServiceCharge() ?: 0, - 'txAmt' => $this->getTaxAmount() ?: 0, - 'tAmt' => $this->getTotalAmount(), - 'pid' => $this->getProductCode(), - 'scd' => $this->getMerchantCode(), - 'su' => $this->getReturnUrl(), - 'fu' => $this->getFailedUrl(), - ]; - } - - /** - * @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 dca4396..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 7bce36e..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 5129181..0000000 --- a/src/SecureGateway.php +++ /dev/null @@ -1,182 +0,0 @@ - '', - 'testMode' => false, - ]; - } - - /** - * @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 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); - } -} diff --git a/src/Service/CallbackVerifier.php b/src/Service/CallbackVerifier.php new file mode 100644 index 0000000..31150ed --- /dev/null +++ b/src/Service/CallbackVerifier.php @@ -0,0 +1,70 @@ +decodedData(); + + $totalAmount = (string)($data['total_amount'] ?? ''); + $transactionUuid = (string)($data['transaction_uuid'] ?? ''); + $productCode = (string)($data['product_code'] ?? ''); + $signedFieldNames = (string)($data['signed_field_names'] ?? 'total_amount,transaction_uuid,product_code'); + $status = PaymentStatus::fromValue((string)($data['status'] ?? null)); + $referenceId = isset($data['transaction_code']) ? (string)$data['transaction_code'] : null; + + $validSignature = $this->signatures->verify( + $payload->signature, + $totalAmount, + $transactionUuid, + $productCode, + $signedFieldNames + ); + + if (!$validSignature) { + return new VerificationResult(false, $status, $referenceId, 'Invalid callback signature.', $data); + } + + if ($context !== null) { + $this->assertConsistent($context, $totalAmount, $transactionUuid, $productCode, $referenceId); + } + + return new VerificationResult(true, $status, $referenceId, 'Callback verified.', $data); + } + + private function assertConsistent( + VerificationContext $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) { + throw new FraudValidationException('reference_id mismatch during callback verification.'); + } + } +} diff --git a/src/Service/SignatureService.php b/src/Service/SignatureService.php new file mode 100644 index 0000000..f49e292 --- /dev/null +++ b/src/Service/SignatureService.php @@ -0,0 +1,53 @@ + $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/tests/Fakes/FakeTransport.php b/tests/Fakes/FakeTransport.php new file mode 100644 index 0000000..5a23193 --- /dev/null +++ b/tests/Fakes/FakeTransport.php @@ -0,0 +1,37 @@ + */ + 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/Message/PurchaseRequestTest.php b/tests/Message/PurchaseRequestTest.php deleted file mode 100644 index 4e4de26..0000000 --- a/tests/Message/PurchaseRequestTest.php +++ /dev/null @@ -1,44 +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://esewa.com.np/epay/main', $response->getRedirectUrl()); - $this->assertSame('POST', $response->getRedirectMethod()); - - $data = $response->getData(); - $this->assertArrayHasKey('amt', $data); - $this->assertSame('100.00', $data['amt']); - } -} diff --git a/tests/Message/PurchaseResponseTest.php b/tests/Message/PurchaseResponseTest.php deleted file mode 100644 index 57f3769..0000000 --- a/tests/Message/PurchaseResponseTest.php +++ /dev/null @@ -1,25 +0,0 @@ - '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()); - } -} diff --git a/tests/Message/VerifyPaymentRequestTest.php b/tests/Message/VerifyPaymentRequestTest.php deleted file mode 100644 index e600e5f..0000000 --- a/tests/Message/VerifyPaymentRequestTest.php +++ /dev/null @@ -1,52 +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/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 2d20be2..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/CallbackServiceTest.php b/tests/Unit/CallbackServiceTest.php new file mode 100644 index 0000000..336d778 --- /dev/null +++ b/tests/Unit/CallbackServiceTest.php @@ -0,0 +1,86 @@ + 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + ]); + + $gateway = new EsewaGateway($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', + 'transaction_code' => 'R-1001', + ]; + + $signature = (new SignatureService('secret'))->generate( + '100.00', + 'TXN-1001', + 'EPAYTEST', + 'total_amount,transaction_uuid,product_code' + ); + + $payload = new ReturnPayload(base64_encode((string)json_encode($data)), $signature); + + $result = $gateway->callback()->verify($payload, new VerificationContext( + totalAmount: '100.00', + transactionUuid: 'TXN-1001', + productCode: 'EPAYTEST', + referenceId: 'R-1001' + )); + + $this->assertTrue($result->valid); + $this->assertTrue($result->isSuccessful()); + } + + public function testVerifyThrowsOnFraudMismatch(): void + { + $config = Config::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + ]); + + $gateway = new EsewaGateway($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 ReturnPayload(base64_encode((string)json_encode($data)), $signature); + + $this->expectException(FraudValidationException::class); + + $gateway->callback()->verify($payload, new VerificationContext( + 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..14b258f --- /dev/null +++ b/tests/Unit/CheckoutServiceTest.php @@ -0,0 +1,44 @@ + 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + ]), + new FakeTransport([]) + ); + + $intent = $gateway->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/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..aab3162 --- /dev/null +++ b/tests/Unit/TransactionServiceTest.php @@ -0,0 +1,42 @@ + 'COMPLETE', + 'ref_id' => 'REF-123', + ]); + + $gateway = new EsewaGateway( + Config::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + ]), + $fake + ); + + $result = $gateway->transactions()->status(new StatusQuery( + 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']); + } +} From 590927c3e771196b3d87d361777b9c48e71ed64b Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 20 Feb 2026 15:35:55 +0000 Subject: [PATCH 02/10] Apply fixes from StyleCI --- rector.php | 4 +-- src/Client/CallbackService.php | 4 ++- src/Client/CheckoutService.php | 25 ++++++++-------- src/Client/TransactionService.php | 11 +++---- src/Config/Config.php | 6 ++-- src/Config/EndpointResolver.php | 4 +-- src/Contracts/TransportInterface.php | 1 + src/Domain/Checkout/CheckoutIntent.php | 5 ++-- src/Domain/Checkout/CheckoutRequest.php | 14 ++++----- src/Domain/Transaction/PaymentStatus.php | 2 +- src/Domain/Transaction/StatusResult.php | 3 +- src/Domain/Verification/ReturnPayload.php | 4 +-- .../Verification/VerificationContext.php | 3 +- .../Verification/VerificationResult.php | 3 +- src/Exception/ApiErrorException.php | 4 ++- src/Exception/EsewaException.php | 4 ++- src/Exception/FraudValidationException.php | 4 ++- src/Exception/InvalidCallbackException.php | 4 ++- src/Exception/InvalidPayloadException.php | 4 ++- src/Exception/SignatureException.php | 4 ++- src/Exception/TransportException.php | 4 ++- .../Transport/Psr18Transport.php | 12 ++++---- src/Service/CallbackVerifier.php | 16 +++++----- src/Service/SignatureService.php | 9 +++--- tests/Fakes/FakeTransport.php | 1 + tests/Unit/CallbackServiceTest.php | 30 +++++++++---------- tests/Unit/CheckoutServiceTest.php | 4 +-- tests/Unit/TransactionServiceTest.php | 4 +-- 28 files changed, 111 insertions(+), 82 deletions(-) diff --git a/rector.php b/rector.php index ae96a82..0fe84fe 100644 --- a/rector.php +++ b/rector.php @@ -9,8 +9,8 @@ return RectorConfig::configure() ->withPaths([ - __DIR__ . '/src', - __DIR__ . '/tests', + __DIR__.'/src', + __DIR__.'/tests', ]) ->withPhpVersion(PhpVersion::PHP_83) ->withSets([ diff --git a/src/Client/CallbackService.php b/src/Client/CallbackService.php index 2a88d16..1221c4a 100644 --- a/src/Client/CallbackService.php +++ b/src/Client/CallbackService.php @@ -11,7 +11,9 @@ final class CallbackService { - public function __construct(private readonly CallbackVerifier $verifier) {} + public function __construct(private readonly CallbackVerifier $verifier) + { + } public function verify(ReturnPayload $payload, ?VerificationContext $context = null): VerificationResult { diff --git a/src/Client/CheckoutService.php b/src/Client/CheckoutService.php index aeba40c..b500f95 100644 --- a/src/Client/CheckoutService.php +++ b/src/Client/CheckoutService.php @@ -16,7 +16,8 @@ public function __construct( private readonly Config $config, private readonly EndpointResolver $endpoints, private readonly SignatureService $signatures, - ) {} + ) { + } public function createIntent(CheckoutRequest $request): CheckoutIntent { @@ -29,17 +30,17 @@ public function createIntent(CheckoutRequest $request): CheckoutIntent ); $fields = [ - 'amount' => number_format((float)$request->amount, 2, '.', ''), - 'tax_amount' => number_format((float)$request->taxAmount, 2, '.', ''), - 'product_service_charge' => number_format((float)$request->serviceCharge, 2, '.', ''), - 'product_delivery_charge' => number_format((float)$request->deliveryCharge, 2, '.', ''), - 'total_amount' => $totalAmount, - 'transaction_uuid' => $request->transactionUuid, - 'product_code' => $request->productCode, - 'success_url' => $request->successUrl, - 'failure_url' => $request->failureUrl, - 'signed_field_names' => $request->signedFieldNames, - 'signature' => $signature, + 'amount' => number_format((float) $request->amount, 2, '.', ''), + 'tax_amount' => number_format((float) $request->taxAmount, 2, '.', ''), + 'product_service_charge' => number_format((float) $request->serviceCharge, 2, '.', ''), + 'product_delivery_charge' => number_format((float) $request->deliveryCharge, 2, '.', ''), + 'total_amount' => $totalAmount, + 'transaction_uuid' => $request->transactionUuid, + 'product_code' => $request->productCode, + 'success_url' => $request->successUrl, + 'failure_url' => $request->failureUrl, + 'signed_field_names' => $request->signedFieldNames, + 'signature' => $signature, ]; return new CheckoutIntent($this->endpoints->checkoutFormUrl($this->config), $fields); diff --git a/src/Client/TransactionService.php b/src/Client/TransactionService.php index e30c403..bb3dce9 100644 --- a/src/Client/TransactionService.php +++ b/src/Client/TransactionService.php @@ -17,21 +17,22 @@ public function __construct( private readonly Config $config, private readonly EndpointResolver $endpoints, private readonly TransportInterface $transport, - ) {} + ) { + } public function status(StatusQuery $query): StatusResult { $payload = $this->transport->get( $this->endpoints->statusCheckUrl($this->config), [ - 'product_code' => $query->productCode, - 'total_amount' => $query->totalAmount, + 'product_code' => $query->productCode, + 'total_amount' => $query->totalAmount, 'transaction_uuid' => $query->transactionUuid, ] ); - $status = PaymentStatus::fromValue(isset($payload['status']) ? (string)$payload['status'] : null); - $referenceId = isset($payload['ref_id']) ? (string)$payload['ref_id'] : null; + $status = PaymentStatus::fromValue(isset($payload['status']) ? (string) $payload['status'] : null); + $referenceId = isset($payload['ref_id']) ? (string) $payload['ref_id'] : null; return new StatusResult($status, $referenceId, $payload); } diff --git a/src/Config/Config.php b/src/Config/Config.php index 53a9509..5621db2 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -27,9 +27,9 @@ public function __construct( public static function fromArray(array $config): self { return new self( - merchantCode: (string)($config['merchant_code'] ?? ''), - secretKey: (string)($config['secret_key'] ?? ''), - environment: Environment::fromString((string)($config['environment'] ?? 'uat')), + merchantCode: (string) ($config['merchant_code'] ?? ''), + secretKey: (string) ($config['secret_key'] ?? ''), + environment: Environment::fromString((string) ($config['environment'] ?? 'uat')), checkoutFormUrl: $config['checkout_form_url'] ?? null, statusCheckUrl: $config['status_check_url'] ?? null, ); diff --git a/src/Config/EndpointResolver.php b/src/Config/EndpointResolver.php index a4d609d..54ac1e0 100644 --- a/src/Config/EndpointResolver.php +++ b/src/Config/EndpointResolver.php @@ -13,7 +13,7 @@ public function checkoutFormUrl(Config $config): string } return match ($config->environment) { - Environment::UAT => 'https://rc-epay.esewa.com.np/api/epay/main/v2/form', + 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', }; } @@ -25,7 +25,7 @@ public function statusCheckUrl(Config $config): string } return match ($config->environment) { - Environment::UAT => 'https://rc-epay.esewa.com.np/api/epay/transaction/status/', + 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/Contracts/TransportInterface.php b/src/Contracts/TransportInterface.php index 208f115..648c6a5 100644 --- a/src/Contracts/TransportInterface.php +++ b/src/Contracts/TransportInterface.php @@ -9,6 +9,7 @@ interface TransportInterface /** * @param array $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 index 3a5c6f1..a1b6c06 100644 --- a/src/Domain/Checkout/CheckoutIntent.php +++ b/src/Domain/Checkout/CheckoutIntent.php @@ -12,7 +12,8 @@ final class CheckoutIntent public function __construct( public readonly string $actionUrl, public readonly array $fields, - ) {} + ) { + } /** * @return array{action_url:string,fields:array} @@ -21,7 +22,7 @@ public function form(): array { return [ 'action_url' => $this->actionUrl, - 'fields' => $this->fields, + 'fields' => $this->fields, ]; } } diff --git a/src/Domain/Checkout/CheckoutRequest.php b/src/Domain/Checkout/CheckoutRequest.php index c9fbb7b..1558b1d 100644 --- a/src/Domain/Checkout/CheckoutRequest.php +++ b/src/Domain/Checkout/CheckoutRequest.php @@ -19,9 +19,9 @@ public function __construct( ) { foreach ([ 'transactionUuid' => $transactionUuid, - 'productCode' => $productCode, - 'successUrl' => $successUrl, - 'failureUrl' => $failureUrl, + 'productCode' => $productCode, + 'successUrl' => $successUrl, + 'failureUrl' => $failureUrl, ] as $field => $value) { if ($value === '') { throw new \InvalidArgumentException("{$field} is required."); @@ -31,10 +31,10 @@ public function __construct( public function totalAmount(): string { - $total = (float)$this->amount - + (float)$this->taxAmount - + (float)$this->serviceCharge - + (float)$this->deliveryCharge; + $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 index c77175d..393b8c6 100644 --- a/src/Domain/Transaction/PaymentStatus.php +++ b/src/Domain/Transaction/PaymentStatus.php @@ -17,6 +17,6 @@ enum PaymentStatus: string public static function fromValue(?string $value): self { - return self::tryFrom((string)$value) ?? self::UNKNOWN; + return self::tryFrom((string) $value) ?? self::UNKNOWN; } } diff --git a/src/Domain/Transaction/StatusResult.php b/src/Domain/Transaction/StatusResult.php index fca6f7f..b839f61 100644 --- a/src/Domain/Transaction/StatusResult.php +++ b/src/Domain/Transaction/StatusResult.php @@ -13,7 +13,8 @@ public function __construct( public readonly PaymentStatus $status, public readonly ?string $referenceId, public readonly array $raw, - ) {} + ) { + } public function isSuccessful(): bool { diff --git a/src/Domain/Verification/ReturnPayload.php b/src/Domain/Verification/ReturnPayload.php index 209c280..8f04ea7 100644 --- a/src/Domain/Verification/ReturnPayload.php +++ b/src/Domain/Verification/ReturnPayload.php @@ -21,8 +21,8 @@ public function __construct( public static function fromArray(array $payload): self { return new self( - data: (string)($payload['data'] ?? ''), - signature: (string)($payload['signature'] ?? ''), + data: (string) ($payload['data'] ?? ''), + signature: (string) ($payload['signature'] ?? ''), ); } diff --git a/src/Domain/Verification/VerificationContext.php b/src/Domain/Verification/VerificationContext.php index 7a354a1..9b5d1e2 100644 --- a/src/Domain/Verification/VerificationContext.php +++ b/src/Domain/Verification/VerificationContext.php @@ -11,5 +11,6 @@ public function __construct( public readonly string $transactionUuid, public readonly string $productCode, public readonly ?string $referenceId = null, - ) {} + ) { + } } diff --git a/src/Domain/Verification/VerificationResult.php b/src/Domain/Verification/VerificationResult.php index 2823ebd..11688b4 100644 --- a/src/Domain/Verification/VerificationResult.php +++ b/src/Domain/Verification/VerificationResult.php @@ -17,7 +17,8 @@ public function __construct( public readonly ?string $referenceId, public readonly string $message, public readonly array $raw, - ) {} + ) { + } public function isSuccessful(): bool { diff --git a/src/Exception/ApiErrorException.php b/src/Exception/ApiErrorException.php index f5e330a..014c8ad 100644 --- a/src/Exception/ApiErrorException.php +++ b/src/Exception/ApiErrorException.php @@ -4,4 +4,6 @@ namespace EsewaPayment\Exception; -final class ApiErrorException extends EsewaException {} +final class ApiErrorException extends EsewaException +{ +} diff --git a/src/Exception/EsewaException.php b/src/Exception/EsewaException.php index 1ea7aa7..526b9e3 100644 --- a/src/Exception/EsewaException.php +++ b/src/Exception/EsewaException.php @@ -4,4 +4,6 @@ namespace EsewaPayment\Exception; -class EsewaException extends \RuntimeException {} +class EsewaException extends \RuntimeException +{ +} diff --git a/src/Exception/FraudValidationException.php b/src/Exception/FraudValidationException.php index aa29dff..4112492 100644 --- a/src/Exception/FraudValidationException.php +++ b/src/Exception/FraudValidationException.php @@ -4,4 +4,6 @@ namespace EsewaPayment\Exception; -final class FraudValidationException extends EsewaException {} +final class FraudValidationException extends EsewaException +{ +} diff --git a/src/Exception/InvalidCallbackException.php b/src/Exception/InvalidCallbackException.php index a941407..146c165 100644 --- a/src/Exception/InvalidCallbackException.php +++ b/src/Exception/InvalidCallbackException.php @@ -4,4 +4,6 @@ namespace EsewaPayment\Exception; -final class InvalidCallbackException extends EsewaException {} +final class InvalidCallbackException extends EsewaException +{ +} diff --git a/src/Exception/InvalidPayloadException.php b/src/Exception/InvalidPayloadException.php index 08de04b..90b5d4a 100644 --- a/src/Exception/InvalidPayloadException.php +++ b/src/Exception/InvalidPayloadException.php @@ -4,4 +4,6 @@ namespace EsewaPayment\Exception; -final class InvalidPayloadException extends EsewaException {} +final class InvalidPayloadException extends EsewaException +{ +} diff --git a/src/Exception/SignatureException.php b/src/Exception/SignatureException.php index 976c0b0..da98fe3 100644 --- a/src/Exception/SignatureException.php +++ b/src/Exception/SignatureException.php @@ -4,4 +4,6 @@ namespace EsewaPayment\Exception; -final class SignatureException extends EsewaException {} +final class SignatureException extends EsewaException +{ +} diff --git a/src/Exception/TransportException.php b/src/Exception/TransportException.php index cde97e4..d343818 100644 --- a/src/Exception/TransportException.php +++ b/src/Exception/TransportException.php @@ -4,4 +4,6 @@ namespace EsewaPayment\Exception; -final class TransportException extends EsewaException {} +final class TransportException extends EsewaException +{ +} diff --git a/src/Infrastructure/Transport/Psr18Transport.php b/src/Infrastructure/Transport/Psr18Transport.php index 3176452..ac3b01c 100644 --- a/src/Infrastructure/Transport/Psr18Transport.php +++ b/src/Infrastructure/Transport/Psr18Transport.php @@ -15,16 +15,18 @@ final class Psr18Transport implements TransportInterface public function __construct( private readonly ClientInterface $http, private readonly RequestFactoryInterface $requests, - ) {} + ) { + } /** * @param array $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); + $fullUrl = $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query); $request = $this->requests->createRequest('GET', $fullUrl); foreach ($headers as $name => $value) { @@ -34,14 +36,14 @@ public function get(string $url, array $query = [], array $headers = []): array try { $response = $this->http->sendRequest($request); } catch (\Throwable $e) { - throw new TransportException('HTTP request failed: ' . $e->getMessage(), 0, $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()); + throw new TransportException('Unexpected HTTP status: '.$response->getStatusCode()); } - $decoded = json_decode((string)$response->getBody(), true); + $decoded = json_decode((string) $response->getBody(), true); if (!is_array($decoded)) { throw new ApiErrorException('Invalid JSON response from eSewa status API.'); } diff --git a/src/Service/CallbackVerifier.php b/src/Service/CallbackVerifier.php index 31150ed..5836ab4 100644 --- a/src/Service/CallbackVerifier.php +++ b/src/Service/CallbackVerifier.php @@ -12,18 +12,20 @@ final class CallbackVerifier { - public function __construct(private readonly SignatureService $signatures) {} + public function __construct(private readonly SignatureService $signatures) + { + } public function verify(ReturnPayload $payload, ?VerificationContext $context = null): VerificationResult { $data = $payload->decodedData(); - $totalAmount = (string)($data['total_amount'] ?? ''); - $transactionUuid = (string)($data['transaction_uuid'] ?? ''); - $productCode = (string)($data['product_code'] ?? ''); - $signedFieldNames = (string)($data['signed_field_names'] ?? 'total_amount,transaction_uuid,product_code'); - $status = PaymentStatus::fromValue((string)($data['status'] ?? null)); - $referenceId = isset($data['transaction_code']) ? (string)$data['transaction_code'] : null; + $totalAmount = (string) ($data['total_amount'] ?? ''); + $transactionUuid = (string) ($data['transaction_uuid'] ?? ''); + $productCode = (string) ($data['product_code'] ?? ''); + $signedFieldNames = (string) ($data['signed_field_names'] ?? 'total_amount,transaction_uuid,product_code'); + $status = PaymentStatus::fromValue((string) ($data['status'] ?? null)); + $referenceId = isset($data['transaction_code']) ? (string) $data['transaction_code'] : null; $validSignature = $this->signatures->verify( $payload->signature, diff --git a/src/Service/SignatureService.php b/src/Service/SignatureService.php index f49e292..a86eafa 100644 --- a/src/Service/SignatureService.php +++ b/src/Service/SignatureService.php @@ -9,7 +9,8 @@ final class SignatureService public function __construct( #[\SensitiveParameter] private readonly string $secretKey, - ) {} + ) { + } public function generate( string $totalAmount, @@ -20,9 +21,9 @@ public function generate( $fields = array_map('trim', explode(',', $signedFieldNames)); $values = [ - 'total_amount' => $totalAmount, + 'total_amount' => $totalAmount, 'transaction_uuid' => $transactionUuid, - 'product_code' => $productCode, + 'product_code' => $productCode, ]; $parts = []; @@ -31,7 +32,7 @@ public function generate( continue; } - $parts[] = $field . '=' . $values[$field]; + $parts[] = $field.'='.$values[$field]; } $message = implode(',', $parts); diff --git a/tests/Fakes/FakeTransport.php b/tests/Fakes/FakeTransport.php index 5a23193..47a0233 100644 --- a/tests/Fakes/FakeTransport.php +++ b/tests/Fakes/FakeTransport.php @@ -25,6 +25,7 @@ public function __construct(array $next) /** * @param array $query * @param array $headers + * * @return array */ public function get(string $url, array $query = [], array $headers = []): array diff --git a/tests/Unit/CallbackServiceTest.php b/tests/Unit/CallbackServiceTest.php index 336d778..18e2506 100644 --- a/tests/Unit/CallbackServiceTest.php +++ b/tests/Unit/CallbackServiceTest.php @@ -19,19 +19,19 @@ public function testVerifyValidCallbackPayload(): void { $config = Config::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', + 'secret_key' => 'secret', + 'environment' => 'uat', ]); $gateway = new EsewaGateway($config, new FakeTransport([])); $data = [ - 'status' => 'COMPLETE', - 'transaction_uuid' => 'TXN-1001', - 'total_amount' => '100.00', - 'product_code' => 'EPAYTEST', + 'status' => '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', + 'transaction_code' => 'R-1001', ]; $signature = (new SignatureService('secret'))->generate( @@ -41,7 +41,7 @@ public function testVerifyValidCallbackPayload(): void 'total_amount,transaction_uuid,product_code' ); - $payload = new ReturnPayload(base64_encode((string)json_encode($data)), $signature); + $payload = new ReturnPayload(base64_encode((string) json_encode($data)), $signature); $result = $gateway->callback()->verify($payload, new VerificationContext( totalAmount: '100.00', @@ -58,22 +58,22 @@ public function testVerifyThrowsOnFraudMismatch(): void { $config = Config::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', + 'secret_key' => 'secret', + 'environment' => 'uat', ]); $gateway = new EsewaGateway($config, new FakeTransport([])); $data = [ - 'status' => 'COMPLETE', - 'transaction_uuid' => 'TXN-1001', - 'total_amount' => '100.00', - 'product_code' => 'EPAYTEST', + '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 ReturnPayload(base64_encode((string)json_encode($data)), $signature); + $payload = new ReturnPayload(base64_encode((string) json_encode($data)), $signature); $this->expectException(FraudValidationException::class); diff --git a/tests/Unit/CheckoutServiceTest.php b/tests/Unit/CheckoutServiceTest.php index 14b258f..d59a301 100644 --- a/tests/Unit/CheckoutServiceTest.php +++ b/tests/Unit/CheckoutServiceTest.php @@ -17,8 +17,8 @@ public function testCreateIntentBuildsFormFields(): void $gateway = new EsewaGateway( Config::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', + 'secret_key' => 'secret', + 'environment' => 'uat', ]), new FakeTransport([]) ); diff --git a/tests/Unit/TransactionServiceTest.php b/tests/Unit/TransactionServiceTest.php index aab3162..7553369 100644 --- a/tests/Unit/TransactionServiceTest.php +++ b/tests/Unit/TransactionServiceTest.php @@ -23,8 +23,8 @@ public function testStatusMapsResponseAndQuery(): void $gateway = new EsewaGateway( Config::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', + 'secret_key' => 'secret', + 'environment' => 'uat', ]), $fake ); From 97e18f5a9835cd6c4702470bb51bbe3cb5ef60e3 Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Fri, 20 Feb 2026 21:43:03 +0545 Subject: [PATCH 03/10] feat: modernize SDK API, payload models, tests, and README --- .github/workflows/ci.yml | 2 +- README.md | 310 ++++++++++++++++-- composer.json | 4 +- src/Client/CallbackService.php | 13 +- src/Client/CheckoutService.php | 33 +- .../{EsewaGateway.php => EsewaClient.php} | 11 +- src/Client/TransactionService.php | 20 +- src/Config/EndpointResolver.php | 4 +- src/Config/{Config.php => GatewayConfig.php} | 2 +- src/Domain/Checkout/CheckoutIntent.php | 14 +- src/Domain/Checkout/CheckoutPayload.php | 40 +++ ...StatusResult.php => TransactionStatus.php} | 2 +- .../Transaction/TransactionStatusPayload.php | 32 ++ ...Query.php => TransactionStatusRequest.php} | 2 +- src/Domain/Verification/CallbackData.php | 46 +++ ...{ReturnPayload.php => CallbackPayload.php} | 7 +- ...ionResult.php => CallbackVerification.php} | 2 +- ...ontext.php => VerificationExpectation.php} | 2 +- src/EsewaPayment.php | 13 +- src/Service/CallbackVerifier.php | 38 +-- tests/Unit/CallbackPayloadTest.php | 71 ++++ tests/Unit/CallbackServiceTest.php | 53 ++- tests/Unit/CheckoutServiceTest.php | 8 +- tests/Unit/DomainValidationTest.php | 57 ++++ tests/Unit/EndpointResolverTest.php | 69 ++++ tests/Unit/GatewayConfigTest.php | 57 ++++ tests/Unit/TransactionServiceTest.php | 54 ++- 27 files changed, 848 insertions(+), 118 deletions(-) rename src/Client/{EsewaGateway.php => EsewaClient.php} (85%) rename src/Config/{Config.php => GatewayConfig.php} (97%) create mode 100644 src/Domain/Checkout/CheckoutPayload.php rename src/Domain/Transaction/{StatusResult.php => TransactionStatus.php} (93%) create mode 100644 src/Domain/Transaction/TransactionStatusPayload.php rename src/Domain/Transaction/{StatusQuery.php => TransactionStatusRequest.php} (92%) create mode 100644 src/Domain/Verification/CallbackData.php rename src/Domain/Verification/{ReturnPayload.php => CallbackPayload.php} (89%) rename src/Domain/Verification/{VerificationResult.php => CallbackVerification.php} (94%) rename src/Domain/Verification/{VerificationContext.php => VerificationExpectation.php} (89%) create mode 100644 tests/Unit/CallbackPayloadTest.php create mode 100644 tests/Unit/DomainValidationTest.php create mode 100644 tests/Unit/EndpointResolverTest.php create mode 100644 tests/Unit/GatewayConfigTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f05dac3..5b76aba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.3', '8.4', '8.5'] + php: ['8.1', '8.2', '8.3', '8.4', '8.5'] dependency-version: ['prefer-stable', 'prefer-lowest'] steps: diff --git a/README.md b/README.md index 9701fb9..4bca591 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,38 @@ # EsewaPayment PHP SDK -A modern, framework-agnostic eSewa ePay v2 SDK for PHP. +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 -- HMAC-SHA256 + base64 signature handling -- Callback/return payload verification -- Status check API client -- Anti-fraud field consistency checks -- PSR-18 transport architecture -- PHP 8.3 to 8.5 support +- 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) +- [Framework Integration Examples](#framework-integration-examples) +- [Custom Transport and Testing](#custom-transport-and-testing) +- [Error Handling](#error-handling) +- [Migration Notes](#migration-notes) +- [Development](#development) ## Installation @@ -18,7 +40,7 @@ A modern, framework-agnostic eSewa ePay v2 SDK for PHP. composer require sudiptpa/esewa-payment ``` -Optional adapters used in examples: +For PSR-18 usage examples: ```bash composer require symfony/http-client nyholm/psr7 @@ -27,29 +49,279 @@ composer require symfony/http-client nyholm/psr7 ## Quick Start ```php -use EsewaPayment\Client\EsewaGateway; -use EsewaPayment\Config\Config; + 'EPAYTEST', - 'secret_key' => 'YOUR_SECRET', - 'environment' => 'uat', + 'secret_key' => $_ENV['ESEWA_SECRET_KEY'], + 'environment' => 'uat', // uat|test|sandbox|production|prod|live ]); -$gateway = new EsewaGateway( +$client = new EsewaClient( $config, new Psr18Transport(new Psr18Client(), new Psr17Factory()) ); ``` -## Modules +## Core API Shape + +Main client and modules: + +- `EsewaClient` +- `$client->checkout()` +- `$client->callbacks()` (alias: `$client->callback()`) +- `$client->transactions()` + +Primary model objects: + +- `CheckoutRequest` +- `CheckoutIntent` +- `CallbackPayload` +- `VerificationExpectation` +- `CallbackVerification` +- `TransactionStatusRequest` +- `TransactionStatus` + +Static convenience entry point: + +```php +use EsewaPayment\EsewaPayment; + +$client = EsewaPayment::client($config, $transport); // alias: EsewaPayment::gateway(...) +``` + +## Checkout Flow + +### 1) Build a checkout intent + +```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 +} +``` + +### 3) Verify without context (signature only) + +```php +$verification = $client->callbacks()->verify($payload); +``` + +## Transaction Status Flow + +```php +use EsewaPayment\Domain\Transaction\TransactionStatusRequest; + +$status = $client->transactions()->fetchStatus(new TransactionStatusRequest( + transactionUuid: 'TXN-1001', + totalAmount: '100.00', + productCode: 'EPAYTEST', +)); + +if ($status->isSuccessful()) { + // COMPLETE +} + +echo $status->status->value; // PENDING|COMPLETE|FULL_REFUND|PARTIAL_REFUND|AMBIGUOUS|NOT_FOUND|CANCELED|UNKNOWN +``` + +Alias method: + +```php +$status = $client->transactions()->status($request); +``` + +## 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::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + 'checkout_form_url' => 'https://custom-esewa.example/api/epay/main/v2/form', + 'status_check_url' => 'https://custom-esewa.example/api/epay/transaction/status/', +]); +``` + +## Framework Integration Examples + +### Laravel service container binding + +```php +use EsewaPayment\Client\EsewaClient; +use EsewaPayment\Config\GatewayConfig; +use EsewaPayment\Infrastructure\Transport\Psr18Transport; +use Nyholm\Psr7\Factory\Psr17Factory; +use Symfony\Component\HttpClient\Psr18Client; + +$this->app->singleton(EsewaClient::class, function () { + $config = GatewayConfig::fromArray([ + 'merchant_code' => config('services.esewa.merchant_code'), + 'secret_key' => config('services.esewa.secret_key'), + 'environment' => config('services.esewa.environment', 'uat'), + ]); + + return new EsewaClient( + $config, + new Psr18Transport(new Psr18Client(), new Psr17Factory()) + ); +}); +``` + +### Symfony-style service wiring + +- Register `GatewayConfig` as a service parameter object +- Inject `EsewaClient` into controllers/services +- Use `checkout`, `callbacks`, and `transactions` modules in your application service layer + +## Custom Transport and Testing + +You can use any custom transport implementing `TransportInterface`. + +```php +use EsewaPayment\Contracts\TransportInterface; + +final class FakeTransport implements TransportInterface +{ + public function get(string $url, array $query = [], array $headers = []): array + { + return ['status' => 'COMPLETE', 'ref_id' => 'REF-123']; + } +} +``` + +Inject it: + +```php +$client = new EsewaClient($config, new FakeTransport()); +``` + +## Error Handling + +Key exceptions: + +- `InvalidPayloadException` +- `FraudValidationException` +- `TransportException` +- `ApiErrorException` +- Base: `EsewaException` + +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 +} +``` + +## Migration Notes + +Current preferred naming: + +- `EsewaClient` (old internal name: `EsewaGateway`) +- `GatewayConfig` (old internal name: `Config`) +- `CallbackPayload` / `VerificationExpectation` / `CallbackVerification` +- `TransactionStatusRequest` / `TransactionStatus` + +Compatibility aliases still available: -- `checkout()->createIntent(...)` -- `callback()->verify(...)` -- `transactions()->status(...)` +- `EsewaPayment::gateway(...)` (preferred: `EsewaPayment::client(...)`) +- `$client->callback()` (preferred: `$client->callbacks()`) +- `$client->callbacks()->verify(...)` (preferred: `verifyCallback(...)`) +- `$client->transactions()->status(...)` (preferred: `fetchStatus(...)`) ## Development diff --git a/composer.json b/composer.json index e0b55d9..97d0a6f 100644 --- a/composer.json +++ b/composer.json @@ -17,13 +17,13 @@ } ], "require": { - "php": ">=8.3 <8.6", + "php": ">=8.1 <8.6", "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^2.0" }, "require-dev": { - "phpunit/phpunit": "^11.0", + "phpunit/phpunit": "^10.5 || ^11.0", "phpstan/phpstan": "^1.11", "rector/rector": "^1.2", "symfony/http-client": "^7.0 || ^8.0", diff --git a/src/Client/CallbackService.php b/src/Client/CallbackService.php index 1221c4a..13fe0ae 100644 --- a/src/Client/CallbackService.php +++ b/src/Client/CallbackService.php @@ -4,9 +4,9 @@ namespace EsewaPayment\Client; -use EsewaPayment\Domain\Verification\ReturnPayload; -use EsewaPayment\Domain\Verification\VerificationContext; -use EsewaPayment\Domain\Verification\VerificationResult; +use EsewaPayment\Domain\Verification\CallbackPayload; +use EsewaPayment\Domain\Verification\VerificationExpectation; +use EsewaPayment\Domain\Verification\CallbackVerification; use EsewaPayment\Service\CallbackVerifier; final class CallbackService @@ -15,7 +15,12 @@ public function __construct(private readonly CallbackVerifier $verifier) { } - public function verify(ReturnPayload $payload, ?VerificationContext $context = null): VerificationResult + public function verifyCallback(CallbackPayload $payload, ?VerificationExpectation $context = null): CallbackVerification + { + return $this->verify($payload, $context); + } + + public function verify(CallbackPayload $payload, ?VerificationExpectation $context = null): CallbackVerification { return $this->verifier->verify($payload, $context); } diff --git a/src/Client/CheckoutService.php b/src/Client/CheckoutService.php index b500f95..3774f94 100644 --- a/src/Client/CheckoutService.php +++ b/src/Client/CheckoutService.php @@ -4,16 +4,17 @@ namespace EsewaPayment\Client; -use EsewaPayment\Config\Config; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Config\EndpointResolver; use EsewaPayment\Domain\Checkout\CheckoutIntent; +use EsewaPayment\Domain\Checkout\CheckoutPayload; use EsewaPayment\Domain\Checkout\CheckoutRequest; use EsewaPayment\Service\SignatureService; final class CheckoutService { public function __construct( - private readonly Config $config, + private readonly GatewayConfig $config, private readonly EndpointResolver $endpoints, private readonly SignatureService $signatures, ) { @@ -29,20 +30,20 @@ public function createIntent(CheckoutRequest $request): CheckoutIntent $request->signedFieldNames, ); - $fields = [ - 'amount' => number_format((float) $request->amount, 2, '.', ''), - 'tax_amount' => number_format((float) $request->taxAmount, 2, '.', ''), - 'product_service_charge' => number_format((float) $request->serviceCharge, 2, '.', ''), - 'product_delivery_charge' => number_format((float) $request->deliveryCharge, 2, '.', ''), - 'total_amount' => $totalAmount, - 'transaction_uuid' => $request->transactionUuid, - 'product_code' => $request->productCode, - 'success_url' => $request->successUrl, - 'failure_url' => $request->failureUrl, - 'signed_field_names' => $request->signedFieldNames, - 'signature' => $signature, - ]; + $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), $fields); + return new CheckoutIntent($this->endpoints->checkoutFormUrl($this->config), $payload); } } diff --git a/src/Client/EsewaGateway.php b/src/Client/EsewaClient.php similarity index 85% rename from src/Client/EsewaGateway.php rename to src/Client/EsewaClient.php index 1996fd7..2496424 100644 --- a/src/Client/EsewaGateway.php +++ b/src/Client/EsewaClient.php @@ -4,20 +4,20 @@ namespace EsewaPayment\Client; -use EsewaPayment\Config\Config; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Config\EndpointResolver; use EsewaPayment\Contracts\TransportInterface; use EsewaPayment\Service\CallbackVerifier; use EsewaPayment\Service\SignatureService; -final class EsewaGateway +final class EsewaClient { private readonly CheckoutService $checkout; private readonly CallbackService $callback; private readonly TransactionService $transactions; public function __construct( - Config $config, + GatewayConfig $config, TransportInterface $transport, ) { $endpoints = new EndpointResolver(); @@ -38,6 +38,11 @@ public function callback(): CallbackService return $this->callback; } + 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 index bb3dce9..fca09e7 100644 --- a/src/Client/TransactionService.php +++ b/src/Client/TransactionService.php @@ -4,23 +4,23 @@ namespace EsewaPayment\Client; -use EsewaPayment\Config\Config; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Config\EndpointResolver; use EsewaPayment\Contracts\TransportInterface; -use EsewaPayment\Domain\Transaction\PaymentStatus; -use EsewaPayment\Domain\Transaction\StatusQuery; -use EsewaPayment\Domain\Transaction\StatusResult; +use EsewaPayment\Domain\Transaction\TransactionStatusPayload; +use EsewaPayment\Domain\Transaction\TransactionStatusRequest; +use EsewaPayment\Domain\Transaction\TransactionStatus; final class TransactionService { public function __construct( - private readonly Config $config, + private readonly GatewayConfig $config, private readonly EndpointResolver $endpoints, private readonly TransportInterface $transport, ) { } - public function status(StatusQuery $query): StatusResult + public function fetchStatus(TransactionStatusRequest $query): TransactionStatus { $payload = $this->transport->get( $this->endpoints->statusCheckUrl($this->config), @@ -31,9 +31,11 @@ public function status(StatusQuery $query): StatusResult ] ); - $status = PaymentStatus::fromValue(isset($payload['status']) ? (string) $payload['status'] : null); - $referenceId = isset($payload['ref_id']) ? (string) $payload['ref_id'] : null; + return TransactionStatusPayload::fromArray($payload)->toResult(); + } - return new StatusResult($status, $referenceId, $payload); + public function status(TransactionStatusRequest $query): TransactionStatus + { + return $this->fetchStatus($query); } } diff --git a/src/Config/EndpointResolver.php b/src/Config/EndpointResolver.php index 54ac1e0..f80fdf0 100644 --- a/src/Config/EndpointResolver.php +++ b/src/Config/EndpointResolver.php @@ -6,7 +6,7 @@ final class EndpointResolver { - public function checkoutFormUrl(Config $config): string + public function checkoutFormUrl(GatewayConfig $config): string { if ($config->checkoutFormUrl !== null && $config->checkoutFormUrl !== '') { return $config->checkoutFormUrl; @@ -18,7 +18,7 @@ public function checkoutFormUrl(Config $config): string }; } - public function statusCheckUrl(Config $config): string + public function statusCheckUrl(GatewayConfig $config): string { if ($config->statusCheckUrl !== null && $config->statusCheckUrl !== '') { return $config->statusCheckUrl; diff --git a/src/Config/Config.php b/src/Config/GatewayConfig.php similarity index 97% rename from src/Config/Config.php rename to src/Config/GatewayConfig.php index 5621db2..e402494 100644 --- a/src/Config/Config.php +++ b/src/Config/GatewayConfig.php @@ -4,7 +4,7 @@ namespace EsewaPayment\Config; -final class Config +final class GatewayConfig { public function __construct( public readonly string $merchantCode, diff --git a/src/Domain/Checkout/CheckoutIntent.php b/src/Domain/Checkout/CheckoutIntent.php index a1b6c06..4448726 100644 --- a/src/Domain/Checkout/CheckoutIntent.php +++ b/src/Domain/Checkout/CheckoutIntent.php @@ -6,13 +6,15 @@ final class CheckoutIntent { - /** - * @param array $fields - */ public function __construct( public readonly string $actionUrl, - public readonly array $fields, - ) { + public readonly CheckoutPayload $payload, + ) {} + + /** @return array */ + public function fields(): array + { + return $this->payload->toArray(); } /** @@ -22,7 +24,7 @@ public function form(): array { return [ 'action_url' => $this->actionUrl, - 'fields' => $this->fields, + 'fields' => $this->fields(), ]; } } diff --git a/src/Domain/Checkout/CheckoutPayload.php b/src/Domain/Checkout/CheckoutPayload.php new file mode 100644 index 0000000..eac66c5 --- /dev/null +++ b/src/Domain/Checkout/CheckoutPayload.php @@ -0,0 +1,40 @@ + */ + 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/Transaction/StatusResult.php b/src/Domain/Transaction/TransactionStatus.php similarity index 93% rename from src/Domain/Transaction/StatusResult.php rename to src/Domain/Transaction/TransactionStatus.php index b839f61..43e54fe 100644 --- a/src/Domain/Transaction/StatusResult.php +++ b/src/Domain/Transaction/TransactionStatus.php @@ -4,7 +4,7 @@ namespace EsewaPayment\Domain\Transaction; -final class StatusResult +final class TransactionStatus { /** * @param array $raw diff --git a/src/Domain/Transaction/TransactionStatusPayload.php b/src/Domain/Transaction/TransactionStatusPayload.php new file mode 100644 index 0000000..ab698f3 --- /dev/null +++ b/src/Domain/Transaction/TransactionStatusPayload.php @@ -0,0 +1,32 @@ + $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/StatusQuery.php b/src/Domain/Transaction/TransactionStatusRequest.php similarity index 92% rename from src/Domain/Transaction/StatusQuery.php rename to src/Domain/Transaction/TransactionStatusRequest.php index 9da5474..c8f5fbe 100644 --- a/src/Domain/Transaction/StatusQuery.php +++ b/src/Domain/Transaction/TransactionStatusRequest.php @@ -4,7 +4,7 @@ namespace EsewaPayment\Domain\Transaction; -final class StatusQuery +final class TransactionStatusRequest { public function __construct( public readonly string $transactionUuid, diff --git a/src/Domain/Verification/CallbackData.php b/src/Domain/Verification/CallbackData.php new file mode 100644 index 0000000..9e325e8 --- /dev/null +++ b/src/Domain/Verification/CallbackData.php @@ -0,0 +1,46 @@ + $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/ReturnPayload.php b/src/Domain/Verification/CallbackPayload.php similarity index 89% rename from src/Domain/Verification/ReturnPayload.php rename to src/Domain/Verification/CallbackPayload.php index 8f04ea7..240f731 100644 --- a/src/Domain/Verification/ReturnPayload.php +++ b/src/Domain/Verification/CallbackPayload.php @@ -6,7 +6,7 @@ use EsewaPayment\Exception\InvalidPayloadException; -final class ReturnPayload +final class CallbackPayload { public function __construct( public readonly string $data, @@ -26,8 +26,7 @@ public static function fromArray(array $payload): self ); } - /** @return array */ - public function decodedData(): array + public function decodedData(): CallbackData { $decoded = base64_decode($this->data, true); if ($decoded === false) { @@ -39,6 +38,6 @@ public function decodedData(): array throw new InvalidPayloadException('Callback data is not valid JSON.'); } - return $json; + return CallbackData::fromArray($json); } } diff --git a/src/Domain/Verification/VerificationResult.php b/src/Domain/Verification/CallbackVerification.php similarity index 94% rename from src/Domain/Verification/VerificationResult.php rename to src/Domain/Verification/CallbackVerification.php index 11688b4..be7c7f0 100644 --- a/src/Domain/Verification/VerificationResult.php +++ b/src/Domain/Verification/CallbackVerification.php @@ -6,7 +6,7 @@ use EsewaPayment\Domain\Transaction\PaymentStatus; -final class VerificationResult +final class CallbackVerification { /** * @param array $raw diff --git a/src/Domain/Verification/VerificationContext.php b/src/Domain/Verification/VerificationExpectation.php similarity index 89% rename from src/Domain/Verification/VerificationContext.php rename to src/Domain/Verification/VerificationExpectation.php index 9b5d1e2..682ed23 100644 --- a/src/Domain/Verification/VerificationContext.php +++ b/src/Domain/Verification/VerificationExpectation.php @@ -4,7 +4,7 @@ namespace EsewaPayment\Domain\Verification; -final class VerificationContext +final class VerificationExpectation { public function __construct( public readonly string $totalAmount, diff --git a/src/EsewaPayment.php b/src/EsewaPayment.php index 27416c4..670a61e 100644 --- a/src/EsewaPayment.php +++ b/src/EsewaPayment.php @@ -4,14 +4,19 @@ namespace EsewaPayment; -use EsewaPayment\Client\EsewaGateway; -use EsewaPayment\Config\Config; +use EsewaPayment\Client\EsewaClient; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Contracts\TransportInterface; final class EsewaPayment { - public static function gateway(Config $config, TransportInterface $transport): EsewaGateway + public static function client(GatewayConfig $config, TransportInterface $transport): EsewaClient { - return new EsewaGateway($config, $transport); + return new EsewaClient($config, $transport); + } + + public static function gateway(GatewayConfig $config, TransportInterface $transport): EsewaClient + { + return self::client($config, $transport); } } diff --git a/src/Service/CallbackVerifier.php b/src/Service/CallbackVerifier.php index 5836ab4..3a5907b 100644 --- a/src/Service/CallbackVerifier.php +++ b/src/Service/CallbackVerifier.php @@ -4,10 +4,9 @@ namespace EsewaPayment\Service; -use EsewaPayment\Domain\Transaction\PaymentStatus; -use EsewaPayment\Domain\Verification\ReturnPayload; -use EsewaPayment\Domain\Verification\VerificationContext; -use EsewaPayment\Domain\Verification\VerificationResult; +use EsewaPayment\Domain\Verification\CallbackPayload; +use EsewaPayment\Domain\Verification\VerificationExpectation; +use EsewaPayment\Domain\Verification\CallbackVerification; use EsewaPayment\Exception\FraudValidationException; final class CallbackVerifier @@ -16,38 +15,37 @@ public function __construct(private readonly SignatureService $signatures) { } - public function verify(ReturnPayload $payload, ?VerificationContext $context = null): VerificationResult + public function verify(CallbackPayload $payload, ?VerificationExpectation $context = null): CallbackVerification { $data = $payload->decodedData(); - $totalAmount = (string) ($data['total_amount'] ?? ''); - $transactionUuid = (string) ($data['transaction_uuid'] ?? ''); - $productCode = (string) ($data['product_code'] ?? ''); - $signedFieldNames = (string) ($data['signed_field_names'] ?? 'total_amount,transaction_uuid,product_code'); - $status = PaymentStatus::fromValue((string) ($data['status'] ?? null)); - $referenceId = isset($data['transaction_code']) ? (string) $data['transaction_code'] : null; - $validSignature = $this->signatures->verify( $payload->signature, - $totalAmount, - $transactionUuid, - $productCode, - $signedFieldNames + $data->totalAmount, + $data->transactionUuid, + $data->productCode, + $data->signedFieldNames ); if (!$validSignature) { - return new VerificationResult(false, $status, $referenceId, 'Invalid callback signature.', $data); + return new CallbackVerification( + false, + $data->status, + $data->transactionCode, + 'Invalid callback signature.', + $data->raw + ); } if ($context !== null) { - $this->assertConsistent($context, $totalAmount, $transactionUuid, $productCode, $referenceId); + $this->assertConsistent($context, $data->totalAmount, $data->transactionUuid, $data->productCode, $data->transactionCode); } - return new VerificationResult(true, $status, $referenceId, 'Callback verified.', $data); + return new CallbackVerification(true, $data->status, $data->transactionCode, 'Callback verified.', $data->raw); } private function assertConsistent( - VerificationContext $context, + VerificationExpectation $context, string $totalAmount, string $transactionUuid, string $productCode, diff --git a/tests/Unit/CallbackPayloadTest.php b/tests/Unit/CallbackPayloadTest.php new file mode 100644 index 0000000..32bd489 --- /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 index 18e2506..130157a 100644 --- a/tests/Unit/CallbackServiceTest.php +++ b/tests/Unit/CallbackServiceTest.php @@ -4,10 +4,11 @@ namespace EsewaPayment\Tests\Unit; -use EsewaPayment\Client\EsewaGateway; -use EsewaPayment\Config\Config; -use EsewaPayment\Domain\Verification\ReturnPayload; -use EsewaPayment\Domain\Verification\VerificationContext; +use EsewaPayment\Client\EsewaClient; +use EsewaPayment\Config\GatewayConfig; +use EsewaPayment\Domain\Transaction\PaymentStatus; +use EsewaPayment\Domain\Verification\CallbackPayload; +use EsewaPayment\Domain\Verification\VerificationExpectation; use EsewaPayment\Exception\FraudValidationException; use EsewaPayment\Service\SignatureService; use EsewaPayment\Tests\Fakes\FakeTransport; @@ -17,13 +18,13 @@ final class CallbackServiceTest extends TestCase { public function testVerifyValidCallbackPayload(): void { - $config = Config::fromArray([ + $config = GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', 'secret_key' => 'secret', 'environment' => 'uat', ]); - $gateway = new EsewaGateway($config, new FakeTransport([])); + $gateway = new EsewaClient($config, new FakeTransport([])); $data = [ 'status' => 'COMPLETE', @@ -41,9 +42,9 @@ public function testVerifyValidCallbackPayload(): void 'total_amount,transaction_uuid,product_code' ); - $payload = new ReturnPayload(base64_encode((string) json_encode($data)), $signature); + $payload = new CallbackPayload(base64_encode((string)json_encode($data)), $signature); - $result = $gateway->callback()->verify($payload, new VerificationContext( + $result = $gateway->callback()->verify($payload, new VerificationExpectation( totalAmount: '100.00', transactionUuid: 'TXN-1001', productCode: 'EPAYTEST', @@ -54,15 +55,41 @@ public function testVerifyValidCallbackPayload(): void $this->assertTrue($result->isSuccessful()); } - public function testVerifyThrowsOnFraudMismatch(): void + public function testVerifyReturnsInvalidResultWhenSignatureIsWrong(): void { - $config = Config::fromArray([ + $config = GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', 'secret_key' => 'secret', 'environment' => 'uat', ]); - $gateway = new EsewaGateway($config, new FakeTransport([])); + $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->callback()->verify($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::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + ]); + + $gateway = new EsewaClient($config, new FakeTransport([])); $data = [ 'status' => 'COMPLETE', @@ -73,11 +100,11 @@ public function testVerifyThrowsOnFraudMismatch(): void ]; $signature = (new SignatureService('secret'))->generate('100.00', 'TXN-1001', 'EPAYTEST'); - $payload = new ReturnPayload(base64_encode((string) json_encode($data)), $signature); + $payload = new CallbackPayload(base64_encode((string)json_encode($data)), $signature); $this->expectException(FraudValidationException::class); - $gateway->callback()->verify($payload, new VerificationContext( + $gateway->callback()->verify($payload, new VerificationExpectation( totalAmount: '99.00', transactionUuid: 'TXN-1001', productCode: 'EPAYTEST' diff --git a/tests/Unit/CheckoutServiceTest.php b/tests/Unit/CheckoutServiceTest.php index d59a301..3c0825a 100644 --- a/tests/Unit/CheckoutServiceTest.php +++ b/tests/Unit/CheckoutServiceTest.php @@ -4,8 +4,8 @@ namespace EsewaPayment\Tests\Unit; -use EsewaPayment\Client\EsewaGateway; -use EsewaPayment\Config\Config; +use EsewaPayment\Client\EsewaClient; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Domain\Checkout\CheckoutRequest; use EsewaPayment\Tests\Fakes\FakeTransport; use PHPUnit\Framework\TestCase; @@ -14,8 +14,8 @@ final class CheckoutServiceTest extends TestCase { public function testCreateIntentBuildsFormFields(): void { - $gateway = new EsewaGateway( - Config::fromArray([ + $gateway = new EsewaClient( + GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', 'secret_key' => 'secret', 'environment' => 'uat', 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..51fe9d9 --- /dev/null +++ b/tests/Unit/EndpointResolverTest.php @@ -0,0 +1,69 @@ + 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + ]); + + $resolver = new EndpointResolver(); + + $this->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::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => '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::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + 'checkout_form_url' => 'https://custom.test/form', + 'status_check_url' => '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/GatewayConfigTest.php b/tests/Unit/GatewayConfigTest.php new file mode 100644 index 0000000..46a6d19 --- /dev/null +++ b/tests/Unit/GatewayConfigTest.php @@ -0,0 +1,57 @@ + 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'live', + 'checkout_form_url' => 'https://checkout.test/form', + 'status_check_url' => 'https://checkout.test/status', + ]); + + $this->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::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'qa', + ]); + } +} diff --git a/tests/Unit/TransactionServiceTest.php b/tests/Unit/TransactionServiceTest.php index 7553369..e6a1889 100644 --- a/tests/Unit/TransactionServiceTest.php +++ b/tests/Unit/TransactionServiceTest.php @@ -4,11 +4,12 @@ namespace EsewaPayment\Tests\Unit; -use EsewaPayment\Client\EsewaGateway; -use EsewaPayment\Config\Config; +use EsewaPayment\Client\EsewaClient; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Domain\Transaction\PaymentStatus; -use EsewaPayment\Domain\Transaction\StatusQuery; +use EsewaPayment\Domain\Transaction\TransactionStatusRequest; use EsewaPayment\Tests\Fakes\FakeTransport; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class TransactionServiceTest extends TestCase @@ -20,8 +21,8 @@ public function testStatusMapsResponseAndQuery(): void 'ref_id' => 'REF-123', ]); - $gateway = new EsewaGateway( - Config::fromArray([ + $gateway = new EsewaClient( + GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', 'secret_key' => 'secret', 'environment' => 'uat', @@ -29,7 +30,7 @@ public function testStatusMapsResponseAndQuery(): void $fake ); - $result = $gateway->transactions()->status(new StatusQuery( + $result = $gateway->transactions()->status(new TransactionStatusRequest( transactionUuid: 'TXN-1001', totalAmount: '100.00', productCode: 'EPAYTEST', @@ -39,4 +40,45 @@ public function testStatusMapsResponseAndQuery(): void $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::fromArray([ + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', + ]), + new FakeTransport([ + 'status' => $apiStatus, + 'ref_id' => 'REF-XYZ', + ]) + ); + + $result = $gateway->transactions()->status(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], + ]; + } } From a88178b46f6d55ab39d74746c10b8d29b918ea90 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 20 Feb 2026 16:02:16 +0000 Subject: [PATCH 04/10] Apply fixes from StyleCI --- src/Client/CallbackService.php | 2 +- src/Client/CheckoutService.php | 10 ++++---- src/Client/EsewaClient.php | 2 +- src/Client/TransactionService.php | 4 ++-- src/Domain/Checkout/CheckoutIntent.php | 5 ++-- src/Domain/Checkout/CheckoutPayload.php | 23 ++++++++++--------- .../Transaction/TransactionStatusPayload.php | 7 +++--- src/Domain/Verification/CallbackData.php | 15 ++++++------ src/Service/CallbackVerifier.php | 2 +- tests/Unit/CallbackPayloadTest.php | 10 ++++---- tests/Unit/CallbackServiceTest.php | 16 ++++++------- tests/Unit/EndpointResolverTest.php | 19 ++++++++------- tests/Unit/GatewayConfigTest.php | 14 +++++------ tests/Unit/TransactionServiceTest.php | 4 ++-- 14 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/Client/CallbackService.php b/src/Client/CallbackService.php index 13fe0ae..ac4de9b 100644 --- a/src/Client/CallbackService.php +++ b/src/Client/CallbackService.php @@ -5,8 +5,8 @@ namespace EsewaPayment\Client; use EsewaPayment\Domain\Verification\CallbackPayload; -use EsewaPayment\Domain\Verification\VerificationExpectation; use EsewaPayment\Domain\Verification\CallbackVerification; +use EsewaPayment\Domain\Verification\VerificationExpectation; use EsewaPayment\Service\CallbackVerifier; final class CallbackService diff --git a/src/Client/CheckoutService.php b/src/Client/CheckoutService.php index 3774f94..1fa1311 100644 --- a/src/Client/CheckoutService.php +++ b/src/Client/CheckoutService.php @@ -4,8 +4,8 @@ namespace EsewaPayment\Client; -use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Config\EndpointResolver; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Domain\Checkout\CheckoutIntent; use EsewaPayment\Domain\Checkout\CheckoutPayload; use EsewaPayment\Domain\Checkout\CheckoutRequest; @@ -31,10 +31,10 @@ public function createIntent(CheckoutRequest $request): CheckoutIntent ); $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, '.', ''), + 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, diff --git a/src/Client/EsewaClient.php b/src/Client/EsewaClient.php index 2496424..8f6cad9 100644 --- a/src/Client/EsewaClient.php +++ b/src/Client/EsewaClient.php @@ -4,8 +4,8 @@ namespace EsewaPayment\Client; -use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Config\EndpointResolver; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Contracts\TransportInterface; use EsewaPayment\Service\CallbackVerifier; use EsewaPayment\Service\SignatureService; diff --git a/src/Client/TransactionService.php b/src/Client/TransactionService.php index fca09e7..e9b22aa 100644 --- a/src/Client/TransactionService.php +++ b/src/Client/TransactionService.php @@ -4,12 +4,12 @@ namespace EsewaPayment\Client; -use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Config\EndpointResolver; +use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Contracts\TransportInterface; +use EsewaPayment\Domain\Transaction\TransactionStatus; use EsewaPayment\Domain\Transaction\TransactionStatusPayload; use EsewaPayment\Domain\Transaction\TransactionStatusRequest; -use EsewaPayment\Domain\Transaction\TransactionStatus; final class TransactionService { diff --git a/src/Domain/Checkout/CheckoutIntent.php b/src/Domain/Checkout/CheckoutIntent.php index 4448726..7896ac0 100644 --- a/src/Domain/Checkout/CheckoutIntent.php +++ b/src/Domain/Checkout/CheckoutIntent.php @@ -9,7 +9,8 @@ final class CheckoutIntent public function __construct( public readonly string $actionUrl, public readonly CheckoutPayload $payload, - ) {} + ) { + } /** @return array */ public function fields(): array @@ -24,7 +25,7 @@ public function form(): array { return [ 'action_url' => $this->actionUrl, - 'fields' => $this->fields(), + 'fields' => $this->fields(), ]; } } diff --git a/src/Domain/Checkout/CheckoutPayload.php b/src/Domain/Checkout/CheckoutPayload.php index eac66c5..aad6b1c 100644 --- a/src/Domain/Checkout/CheckoutPayload.php +++ b/src/Domain/Checkout/CheckoutPayload.php @@ -18,23 +18,24 @@ public function __construct( public readonly string $failureUrl, public readonly string $signedFieldNames, public readonly string $signature, - ) {} + ) { + } /** @return array */ public function toArray(): array { return [ - 'amount' => $this->amount, - 'tax_amount' => $this->taxAmount, - 'product_service_charge' => $this->serviceCharge, + '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, + '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/Transaction/TransactionStatusPayload.php b/src/Domain/Transaction/TransactionStatusPayload.php index ab698f3..112b99b 100644 --- a/src/Domain/Transaction/TransactionStatusPayload.php +++ b/src/Domain/Transaction/TransactionStatusPayload.php @@ -13,14 +13,15 @@ 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, + status: PaymentStatus::fromValue(isset($raw['status']) ? (string) $raw['status'] : null), + referenceId: isset($raw['ref_id']) ? (string) $raw['ref_id'] : null, raw: $raw, ); } diff --git a/src/Domain/Verification/CallbackData.php b/src/Domain/Verification/CallbackData.php index 9e325e8..9fa6036 100644 --- a/src/Domain/Verification/CallbackData.php +++ b/src/Domain/Verification/CallbackData.php @@ -20,14 +20,15 @@ public function __construct( 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'] ?? ''); + $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.'); @@ -37,9 +38,9 @@ public static function fromArray(array $data): 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, + 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/Service/CallbackVerifier.php b/src/Service/CallbackVerifier.php index 3a5907b..004ac3d 100644 --- a/src/Service/CallbackVerifier.php +++ b/src/Service/CallbackVerifier.php @@ -5,8 +5,8 @@ namespace EsewaPayment\Service; use EsewaPayment\Domain\Verification\CallbackPayload; -use EsewaPayment\Domain\Verification\VerificationExpectation; use EsewaPayment\Domain\Verification\CallbackVerification; +use EsewaPayment\Domain\Verification\VerificationExpectation; use EsewaPayment\Exception\FraudValidationException; final class CallbackVerifier diff --git a/tests/Unit/CallbackPayloadTest.php b/tests/Unit/CallbackPayloadTest.php index 32bd489..48dbdf7 100644 --- a/tests/Unit/CallbackPayloadTest.php +++ b/tests/Unit/CallbackPayloadTest.php @@ -42,7 +42,7 @@ public function testDecodedDataThrowsOnInvalidJson(): void public function testDecodedDataThrowsWhenRequiredFieldsMissing(): void { $payload = new CallbackPayload( - base64_encode((string)json_encode(['status' => 'COMPLETE'])), + base64_encode((string) json_encode(['status' => 'COMPLETE'])), 'signature' ); @@ -55,11 +55,11 @@ public function testDecodedDataThrowsWhenRequiredFieldsMissing(): void public function testDecodedDataMapsUnknownStatusToUnknown(): void { $payload = new CallbackPayload( - base64_encode((string)json_encode([ - 'status' => 'SOMETHING_NEW', + base64_encode((string) json_encode([ + 'status' => 'SOMETHING_NEW', 'transaction_uuid' => 'TXN-1001', - 'total_amount' => '100.00', - 'product_code' => 'EPAYTEST', + 'total_amount' => '100.00', + 'product_code' => 'EPAYTEST', ])), 'signature' ); diff --git a/tests/Unit/CallbackServiceTest.php b/tests/Unit/CallbackServiceTest.php index 130157a..b6999e6 100644 --- a/tests/Unit/CallbackServiceTest.php +++ b/tests/Unit/CallbackServiceTest.php @@ -42,7 +42,7 @@ public function testVerifyValidCallbackPayload(): void 'total_amount,transaction_uuid,product_code' ); - $payload = new CallbackPayload(base64_encode((string)json_encode($data)), $signature); + $payload = new CallbackPayload(base64_encode((string) json_encode($data)), $signature); $result = $gateway->callback()->verify($payload, new VerificationExpectation( totalAmount: '100.00', @@ -66,13 +66,13 @@ public function testVerifyReturnsInvalidResultWhenSignatureIsWrong(): void $gateway = new EsewaClient($config, new FakeTransport([])); $data = [ - 'status' => 'COMPLETE', + 'status' => 'COMPLETE', 'transaction_uuid' => 'TXN-1001', - 'total_amount' => '100.00', - 'product_code' => 'EPAYTEST', + 'total_amount' => '100.00', + 'product_code' => 'EPAYTEST', ]; - $payload = new CallbackPayload(base64_encode((string)json_encode($data)), 'wrong-signature'); + $payload = new CallbackPayload(base64_encode((string) json_encode($data)), 'wrong-signature'); $result = $gateway->callback()->verify($payload); $this->assertFalse($result->valid); @@ -85,8 +85,8 @@ public function testVerifyThrowsOnFraudMismatch(): void { $config = GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', + 'secret_key' => 'secret', + 'environment' => 'uat', ]); $gateway = new EsewaClient($config, new FakeTransport([])); @@ -100,7 +100,7 @@ public function testVerifyThrowsOnFraudMismatch(): void ]; $signature = (new SignatureService('secret'))->generate('100.00', 'TXN-1001', 'EPAYTEST'); - $payload = new CallbackPayload(base64_encode((string)json_encode($data)), $signature); + $payload = new CallbackPayload(base64_encode((string) json_encode($data)), $signature); $this->expectException(FraudValidationException::class); diff --git a/tests/Unit/EndpointResolverTest.php b/tests/Unit/EndpointResolverTest.php index 51fe9d9..639ce97 100644 --- a/tests/Unit/EndpointResolverTest.php +++ b/tests/Unit/EndpointResolverTest.php @@ -4,8 +4,8 @@ namespace EsewaPayment\Tests\Unit; -use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Config\EndpointResolver; +use EsewaPayment\Config\GatewayConfig; use PHPUnit\Framework\TestCase; final class EndpointResolverTest extends TestCase @@ -14,8 +14,8 @@ public function testUsesDefaultUatEndpoints(): void { $config = GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', + 'secret_key' => 'secret', + 'environment' => 'uat', ]); $resolver = new EndpointResolver(); @@ -34,8 +34,8 @@ public function testUsesDefaultProductionEndpoints(): void { $config = GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'production', + 'secret_key' => 'secret', + 'environment' => 'production', ]); $resolver = new EndpointResolver(); @@ -53,11 +53,11 @@ public function testUsesDefaultProductionEndpoints(): void public function testUsesOverridesWhenProvided(): void { $config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'uat', 'checkout_form_url' => 'https://custom.test/form', - 'status_check_url' => 'https://custom.test/status', + 'status_check_url' => 'https://custom.test/status', ]); $resolver = new EndpointResolver(); @@ -66,4 +66,3 @@ public function testUsesOverridesWhenProvided(): void $this->assertSame('https://custom.test/status', $resolver->statusCheckUrl($config)); } } - diff --git a/tests/Unit/GatewayConfigTest.php b/tests/Unit/GatewayConfigTest.php index 46a6d19..b1670e2 100644 --- a/tests/Unit/GatewayConfigTest.php +++ b/tests/Unit/GatewayConfigTest.php @@ -4,8 +4,8 @@ namespace EsewaPayment\Tests\Unit; -use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Config\Environment; +use EsewaPayment\Config\GatewayConfig; use PHPUnit\Framework\TestCase; final class GatewayConfigTest extends TestCase @@ -13,11 +13,11 @@ final class GatewayConfigTest extends TestCase public function testFromArrayMapsValuesAndAliases(): void { $config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'live', + 'merchant_code' => 'EPAYTEST', + 'secret_key' => 'secret', + 'environment' => 'live', 'checkout_form_url' => 'https://checkout.test/form', - 'status_check_url' => 'https://checkout.test/status', + 'status_check_url' => 'https://checkout.test/status', ]); $this->assertSame('EPAYTEST', $config->merchantCode); @@ -50,8 +50,8 @@ public function testThrowsOnUnsupportedEnvironment(): void GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'qa', + 'secret_key' => 'secret', + 'environment' => 'qa', ]); } } diff --git a/tests/Unit/TransactionServiceTest.php b/tests/Unit/TransactionServiceTest.php index e6a1889..96ffcb6 100644 --- a/tests/Unit/TransactionServiceTest.php +++ b/tests/Unit/TransactionServiceTest.php @@ -47,8 +47,8 @@ public function testStatusMapsKnownStatuses(string $apiStatus, PaymentStatus $ex $gateway = new EsewaClient( GatewayConfig::fromArray([ 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', + 'secret_key' => 'secret', + 'environment' => 'uat', ]), new FakeTransport([ 'status' => $apiStatus, From 82d1d892c6fbd53bfd6eed16dfa74982f2d2db4e Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Fri, 20 Feb 2026 21:57:03 +0545 Subject: [PATCH 05/10] refactor: remove legacy aliases and finalize modern-only API --- README.md | 63 +++++++--------------- src/Client/CallbackService.php | 5 -- src/Client/EsewaClient.php | 7 +-- src/Client/TransactionService.php | 5 -- src/Config/GatewayConfig.php | 21 +++++++- src/EsewaPayment.php | 5 -- src/Exception/InvalidCallbackException.php | 9 ---- tests/Unit/CallbackServiceTest.php | 38 ++++++------- tests/Unit/CheckoutServiceTest.php | 10 ++-- tests/Unit/EndpointResolverTest.php | 34 ++++++------ tests/Unit/GatewayConfigTest.php | 26 ++++----- tests/Unit/TransactionServiceTest.php | 24 ++++----- 12 files changed, 106 insertions(+), 141 deletions(-) delete mode 100644 src/Exception/InvalidCallbackException.php diff --git a/README.md b/README.md index 4bca591..adb3c4b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ Framework-agnostic eSewa ePay v2 SDK for modern PHP applications. - [Framework Integration Examples](#framework-integration-examples) - [Custom Transport and Testing](#custom-transport-and-testing) - [Error Handling](#error-handling) -- [Migration Notes](#migration-notes) - [Development](#development) ## Installation @@ -59,11 +58,11 @@ use EsewaPayment\Infrastructure\Transport\Psr18Transport; use Nyholm\Psr7\Factory\Psr17Factory; use Symfony\Component\HttpClient\Psr18Client; -$config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => $_ENV['ESEWA_SECRET_KEY'], - 'environment' => 'uat', // uat|test|sandbox|production|prod|live -]); +$config = GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: $_ENV['ESEWA_SECRET_KEY'], + environment: 'uat', // uat|test|sandbox|production|prod|live +); $client = new EsewaClient( $config, @@ -77,7 +76,7 @@ Main client and modules: - `EsewaClient` - `$client->checkout()` -- `$client->callbacks()` (alias: `$client->callback()`) +- `$client->callbacks()` - `$client->transactions()` Primary model objects: @@ -95,7 +94,7 @@ Static convenience entry point: ```php use EsewaPayment\EsewaPayment; -$client = EsewaPayment::client($config, $transport); // alias: EsewaPayment::gateway(...) +$client = EsewaPayment::client($config, $transport); ``` ## Checkout Flow @@ -176,7 +175,7 @@ if (!$verification->valid || !$verification->isSuccessful()) { ### 3) Verify without context (signature only) ```php -$verification = $client->callbacks()->verify($payload); +$verification = $client->callbacks()->verifyCallback($payload); ``` ## Transaction Status Flow @@ -197,12 +196,6 @@ if ($status->isSuccessful()) { echo $status->status->value; // PENDING|COMPLETE|FULL_REFUND|PARTIAL_REFUND|AMBIGUOUS|NOT_FOUND|CANCELED|UNKNOWN ``` -Alias method: - -```php -$status = $client->transactions()->status($request); -``` - ## Configuration Patterns ### Environment aliases @@ -215,13 +208,13 @@ $status = $client->transactions()->status($request); Useful if eSewa documentation/endpoints differ by account region or rollout: ```php -$config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - 'checkout_form_url' => 'https://custom-esewa.example/api/epay/main/v2/form', - 'status_check_url' => 'https://custom-esewa.example/api/epay/transaction/status/', -]); +$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/', +); ``` ## Framework Integration Examples @@ -236,11 +229,11 @@ use Nyholm\Psr7\Factory\Psr17Factory; use Symfony\Component\HttpClient\Psr18Client; $this->app->singleton(EsewaClient::class, function () { - $config = GatewayConfig::fromArray([ - 'merchant_code' => config('services.esewa.merchant_code'), - 'secret_key' => config('services.esewa.secret_key'), - 'environment' => config('services.esewa.environment', 'uat'), - ]); + $config = GatewayConfig::make( + merchantCode: config('services.esewa.merchant_code'), + secretKey: config('services.esewa.secret_key'), + environment: config('services.esewa.environment', 'uat'), + ); return new EsewaClient( $config, @@ -307,22 +300,6 @@ try { } ``` -## Migration Notes - -Current preferred naming: - -- `EsewaClient` (old internal name: `EsewaGateway`) -- `GatewayConfig` (old internal name: `Config`) -- `CallbackPayload` / `VerificationExpectation` / `CallbackVerification` -- `TransactionStatusRequest` / `TransactionStatus` - -Compatibility aliases still available: - -- `EsewaPayment::gateway(...)` (preferred: `EsewaPayment::client(...)`) -- `$client->callback()` (preferred: `$client->callbacks()`) -- `$client->callbacks()->verify(...)` (preferred: `verifyCallback(...)`) -- `$client->transactions()->status(...)` (preferred: `fetchStatus(...)`) - ## Development ```bash diff --git a/src/Client/CallbackService.php b/src/Client/CallbackService.php index ac4de9b..2073d78 100644 --- a/src/Client/CallbackService.php +++ b/src/Client/CallbackService.php @@ -16,11 +16,6 @@ public function __construct(private readonly CallbackVerifier $verifier) } public function verifyCallback(CallbackPayload $payload, ?VerificationExpectation $context = null): CallbackVerification - { - return $this->verify($payload, $context); - } - - public function verify(CallbackPayload $payload, ?VerificationExpectation $context = null): CallbackVerification { return $this->verifier->verify($payload, $context); } diff --git a/src/Client/EsewaClient.php b/src/Client/EsewaClient.php index 8f6cad9..95717d4 100644 --- a/src/Client/EsewaClient.php +++ b/src/Client/EsewaClient.php @@ -33,14 +33,9 @@ public function checkout(): CheckoutService return $this->checkout; } - public function callback(): CallbackService - { - return $this->callback; - } - public function callbacks(): CallbackService { - return $this->callback(); + return $this->callback; } public function transactions(): TransactionService diff --git a/src/Client/TransactionService.php b/src/Client/TransactionService.php index e9b22aa..01c64ed 100644 --- a/src/Client/TransactionService.php +++ b/src/Client/TransactionService.php @@ -33,9 +33,4 @@ public function fetchStatus(TransactionStatusRequest $query): TransactionStatus return TransactionStatusPayload::fromArray($payload)->toResult(); } - - public function status(TransactionStatusRequest $query): TransactionStatus - { - return $this->fetchStatus($query); - } } diff --git a/src/Config/GatewayConfig.php b/src/Config/GatewayConfig.php index e402494..82edcfb 100644 --- a/src/Config/GatewayConfig.php +++ b/src/Config/GatewayConfig.php @@ -23,13 +23,30 @@ public function __construct( } } + public static function make( + string $merchantCode, + #[\SensitiveParameter] + string $secretKey, + Environment|string $environment = Environment::UAT, + ?string $checkoutFormUrl = null, + ?string $statusCheckUrl = null, + ): self { + return new self( + merchantCode: $merchantCode, + secretKey: $secretKey, + environment: is_string($environment) ? Environment::fromString($environment) : $environment, + checkoutFormUrl: $checkoutFormUrl, + statusCheckUrl: $statusCheckUrl, + ); + } + /** @param array $config */ public static function fromArray(array $config): self { - return new self( + return self::make( merchantCode: (string) ($config['merchant_code'] ?? ''), secretKey: (string) ($config['secret_key'] ?? ''), - environment: Environment::fromString((string) ($config['environment'] ?? 'uat')), + environment: (string) ($config['environment'] ?? 'uat'), checkoutFormUrl: $config['checkout_form_url'] ?? null, statusCheckUrl: $config['status_check_url'] ?? null, ); diff --git a/src/EsewaPayment.php b/src/EsewaPayment.php index 670a61e..aa3ec40 100644 --- a/src/EsewaPayment.php +++ b/src/EsewaPayment.php @@ -14,9 +14,4 @@ public static function client(GatewayConfig $config, TransportInterface $transpo { return new EsewaClient($config, $transport); } - - public static function gateway(GatewayConfig $config, TransportInterface $transport): EsewaClient - { - return self::client($config, $transport); - } } diff --git a/src/Exception/InvalidCallbackException.php b/src/Exception/InvalidCallbackException.php deleted file mode 100644 index 146c165..0000000 --- a/src/Exception/InvalidCallbackException.php +++ /dev/null @@ -1,9 +0,0 @@ - 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - ]); + $config = GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + ); $gateway = new EsewaClient($config, new FakeTransport([])); @@ -44,7 +44,7 @@ public function testVerifyValidCallbackPayload(): void $payload = new CallbackPayload(base64_encode((string) json_encode($data)), $signature); - $result = $gateway->callback()->verify($payload, new VerificationExpectation( + $result = $gateway->callbacks()->verifyCallback($payload, new VerificationExpectation( totalAmount: '100.00', transactionUuid: 'TXN-1001', productCode: 'EPAYTEST', @@ -57,11 +57,11 @@ public function testVerifyValidCallbackPayload(): void public function testVerifyReturnsInvalidResultWhenSignatureIsWrong(): void { - $config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - ]); + $config = GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + ); $gateway = new EsewaClient($config, new FakeTransport([])); @@ -72,8 +72,8 @@ public function testVerifyReturnsInvalidResultWhenSignatureIsWrong(): void 'product_code' => 'EPAYTEST', ]; - $payload = new CallbackPayload(base64_encode((string) json_encode($data)), 'wrong-signature'); - $result = $gateway->callback()->verify($payload); + $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); @@ -83,11 +83,11 @@ public function testVerifyReturnsInvalidResultWhenSignatureIsWrong(): void public function testVerifyThrowsOnFraudMismatch(): void { - $config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - ]); + $config = GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + ); $gateway = new EsewaClient($config, new FakeTransport([])); @@ -104,7 +104,7 @@ public function testVerifyThrowsOnFraudMismatch(): void $this->expectException(FraudValidationException::class); - $gateway->callback()->verify($payload, new VerificationExpectation( + $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 index 3c0825a..6099173 100644 --- a/tests/Unit/CheckoutServiceTest.php +++ b/tests/Unit/CheckoutServiceTest.php @@ -15,11 +15,11 @@ final class CheckoutServiceTest extends TestCase public function testCreateIntentBuildsFormFields(): void { $gateway = new EsewaClient( - GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - ]), + GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + ), new FakeTransport([]) ); diff --git a/tests/Unit/EndpointResolverTest.php b/tests/Unit/EndpointResolverTest.php index 639ce97..4032c42 100644 --- a/tests/Unit/EndpointResolverTest.php +++ b/tests/Unit/EndpointResolverTest.php @@ -12,11 +12,11 @@ final class EndpointResolverTest extends TestCase { public function testUsesDefaultUatEndpoints(): void { - $config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - ]); + $config = GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + ); $resolver = new EndpointResolver(); @@ -32,11 +32,11 @@ public function testUsesDefaultUatEndpoints(): void public function testUsesDefaultProductionEndpoints(): void { - $config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'production', - ]); + $config = GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'production', + ); $resolver = new EndpointResolver(); @@ -52,13 +52,13 @@ public function testUsesDefaultProductionEndpoints(): void public function testUsesOverridesWhenProvided(): void { - $config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - 'checkout_form_url' => 'https://custom.test/form', - 'status_check_url' => 'https://custom.test/status', - ]); + $config = GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + checkoutFormUrl: 'https://custom.test/form', + statusCheckUrl: 'https://custom.test/status', + ); $resolver = new EndpointResolver(); diff --git a/tests/Unit/GatewayConfigTest.php b/tests/Unit/GatewayConfigTest.php index b1670e2..03583ef 100644 --- a/tests/Unit/GatewayConfigTest.php +++ b/tests/Unit/GatewayConfigTest.php @@ -10,15 +10,15 @@ final class GatewayConfigTest extends TestCase { - public function testFromArrayMapsValuesAndAliases(): void + public function testMakeMapsValuesAndAliases(): void { - $config = GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'live', - 'checkout_form_url' => 'https://checkout.test/form', - 'status_check_url' => 'https://checkout.test/status', - ]); + $config = GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'live', + checkoutFormUrl: 'https://checkout.test/form', + statusCheckUrl: 'https://checkout.test/status', + ); $this->assertSame('EPAYTEST', $config->merchantCode); $this->assertSame('secret', $config->secretKey); @@ -48,10 +48,10 @@ public function testThrowsOnUnsupportedEnvironment(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Unsupported environment: qa'); - GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'qa', - ]); + GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'qa', + ); } } diff --git a/tests/Unit/TransactionServiceTest.php b/tests/Unit/TransactionServiceTest.php index 96ffcb6..3942c86 100644 --- a/tests/Unit/TransactionServiceTest.php +++ b/tests/Unit/TransactionServiceTest.php @@ -22,15 +22,15 @@ public function testStatusMapsResponseAndQuery(): void ]); $gateway = new EsewaClient( - GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - ]), + GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + ), $fake ); - $result = $gateway->transactions()->status(new TransactionStatusRequest( + $result = $gateway->transactions()->fetchStatus(new TransactionStatusRequest( transactionUuid: 'TXN-1001', totalAmount: '100.00', productCode: 'EPAYTEST', @@ -45,18 +45,18 @@ public function testStatusMapsResponseAndQuery(): void public function testStatusMapsKnownStatuses(string $apiStatus, PaymentStatus $expectedStatus): void { $gateway = new EsewaClient( - GatewayConfig::fromArray([ - 'merchant_code' => 'EPAYTEST', - 'secret_key' => 'secret', - 'environment' => 'uat', - ]), + GatewayConfig::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + ), new FakeTransport([ 'status' => $apiStatus, 'ref_id' => 'REF-XYZ', ]) ); - $result = $gateway->transactions()->status(new TransactionStatusRequest( + $result = $gateway->transactions()->fetchStatus(new TransactionStatusRequest( transactionUuid: 'TXN-1001', totalAmount: '100.00', productCode: 'EPAYTEST', From c8cba382d18803b25be4362737f14a0b9b011af1 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Fri, 20 Feb 2026 16:15:55 +0000 Subject: [PATCH 06/10] Apply fixes from StyleCI --- tests/Unit/CallbackServiceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/CallbackServiceTest.php b/tests/Unit/CallbackServiceTest.php index e314a6b..69970b8 100644 --- a/tests/Unit/CallbackServiceTest.php +++ b/tests/Unit/CallbackServiceTest.php @@ -72,7 +72,7 @@ public function testVerifyReturnsInvalidResultWhenSignatureIsWrong(): void 'product_code' => 'EPAYTEST', ]; - $payload = new CallbackPayload(base64_encode((string)json_encode($data)), 'wrong-signature'); + $payload = new CallbackPayload(base64_encode((string) json_encode($data)), 'wrong-signature'); $result = $gateway->callbacks()->verifyCallback($payload); $this->assertFalse($result->valid); From 194c439dce07e94c7a50d597614d002e265a523f Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Fri, 20 Feb 2026 22:14:11 +0545 Subject: [PATCH 07/10] fix(ci): support PHP 8.1 by allowing symfony/http-client 6.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 97d0a6f..b802ed6 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "phpunit/phpunit": "^10.5 || ^11.0", "phpstan/phpstan": "^1.11", "rector/rector": "^1.2", - "symfony/http-client": "^7.0 || ^8.0", + "symfony/http-client": "^6.4 || ^7.0 || ^8.0", "nyholm/psr7": "^1.8" }, "autoload": { From a16e78bb0ce6325a476c4ff361072fdc107921ff Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Fri, 20 Feb 2026 22:22:20 +0545 Subject: [PATCH 08/10] feat: harden production flows with retries, replay protection, and logging --- README.md | 62 ++++++++- composer.json | 3 +- src/Client/EsewaClient.php | 8 +- src/Client/TransactionService.php | 67 ++++++++-- src/Config/ClientOptions.php | 29 +++++ src/Contracts/IdempotencyStoreInterface.php | 12 ++ src/EsewaPayment.php | 9 +- .../Idempotency/InMemoryIdempotencyStore.php | 23 ++++ .../Idempotency/NullIdempotencyStore.php | 19 +++ src/Service/CallbackVerifier.php | 55 +++++++- tests/Fakes/ArrayLogger.php | 108 ++++++++++++++++ tests/Fakes/FlakyTransport.php | 45 +++++++ tests/Unit/ProductionHardeningTest.php | 118 ++++++++++++++++++ 13 files changed, 541 insertions(+), 17 deletions(-) create mode 100644 src/Config/ClientOptions.php create mode 100644 src/Contracts/IdempotencyStoreInterface.php create mode 100644 src/Infrastructure/Idempotency/InMemoryIdempotencyStore.php create mode 100644 src/Infrastructure/Idempotency/NullIdempotencyStore.php create mode 100644 tests/Fakes/ArrayLogger.php create mode 100644 tests/Fakes/FlakyTransport.php create mode 100644 tests/Unit/ProductionHardeningTest.php diff --git a/README.md b/README.md index adb3c4b..8afd49e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Framework-agnostic eSewa ePay v2 SDK for modern PHP applications. - [Callback Verification Flow](#callback-verification-flow) - [Transaction Status Flow](#transaction-status-flow) - [Configuration Patterns](#configuration-patterns) +- [Production Hardening](#production-hardening) - [Framework Integration Examples](#framework-integration-examples) - [Custom Transport and Testing](#custom-transport-and-testing) - [Error Handling](#error-handling) @@ -53,9 +54,12 @@ composer require symfony/http-client nyholm/psr7 declare(strict_types=1); use EsewaPayment\Client\EsewaClient; +use EsewaPayment\Config\ClientOptions; use EsewaPayment\Config\GatewayConfig; +use EsewaPayment\Infrastructure\Idempotency\InMemoryIdempotencyStore; use EsewaPayment\Infrastructure\Transport\Psr18Transport; use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Log\NullLogger; use Symfony\Component\HttpClient\Psr18Client; $config = GatewayConfig::make( @@ -66,7 +70,14 @@ $config = GatewayConfig::make( $client = new EsewaClient( $config, - new Psr18Transport(new Psr18Client(), new Psr17Factory()) + new Psr18Transport(new Psr18Client(), new Psr17Factory()), + new ClientOptions( + maxStatusRetries: 2, + statusRetryDelayMs: 150, + preventCallbackReplay: true, + idempotencyStore: new InMemoryIdempotencyStore(), + logger: new NullLogger(), + ), ); ``` @@ -217,6 +228,55 @@ $config = GatewayConfig::make( ); ``` +## 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` + ## Framework Integration Examples ### Laravel service container binding diff --git a/composer.json b/composer.json index b802ed6..78ca3cf 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "php": ">=8.1 <8.6", "psr/http-client": "^1.0", "psr/http-factory": "^1.1", - "psr/http-message": "^2.0" + "psr/http-message": "^2.0", + "psr/log": "^1.1 || ^2.0 || ^3.0" }, "require-dev": { "phpunit/phpunit": "^10.5 || ^11.0", diff --git a/src/Client/EsewaClient.php b/src/Client/EsewaClient.php index 95717d4..b47ece3 100644 --- a/src/Client/EsewaClient.php +++ b/src/Client/EsewaClient.php @@ -4,6 +4,7 @@ namespace EsewaPayment\Client; +use EsewaPayment\Config\ClientOptions; use EsewaPayment\Config\EndpointResolver; use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Contracts\TransportInterface; @@ -19,13 +20,16 @@ final class EsewaClient public function __construct( GatewayConfig $config, TransportInterface $transport, + ?ClientOptions $options = null, ) { + $options ??= new ClientOptions(); + $endpoints = new EndpointResolver(); $signatures = new SignatureService($config->secretKey); $this->checkout = new CheckoutService($config, $endpoints, $signatures); - $this->callback = new CallbackService(new CallbackVerifier($signatures)); - $this->transactions = new TransactionService($config, $endpoints, $transport); + $this->callback = new CallbackService(new CallbackVerifier($signatures, $options)); + $this->transactions = new TransactionService($config, $endpoints, $transport, $options); } public function checkout(): CheckoutService diff --git a/src/Client/TransactionService.php b/src/Client/TransactionService.php index 01c64ed..b332365 100644 --- a/src/Client/TransactionService.php +++ b/src/Client/TransactionService.php @@ -4,12 +4,14 @@ namespace EsewaPayment\Client; +use EsewaPayment\Config\ClientOptions; use EsewaPayment\Config\EndpointResolver; use EsewaPayment\Config\GatewayConfig; use EsewaPayment\Contracts\TransportInterface; use EsewaPayment\Domain\Transaction\TransactionStatus; use EsewaPayment\Domain\Transaction\TransactionStatusPayload; use EsewaPayment\Domain\Transaction\TransactionStatusRequest; +use EsewaPayment\Exception\TransportException; final class TransactionService { @@ -17,20 +19,65 @@ public function __construct( private readonly GatewayConfig $config, private readonly EndpointResolver $endpoints, private readonly TransportInterface $transport, + private readonly ClientOptions $options, ) { } public function fetchStatus(TransactionStatusRequest $query): TransactionStatus { - $payload = $this->transport->get( - $this->endpoints->statusCheckUrl($this->config), - [ - 'product_code' => $query->productCode, - 'total_amount' => $query->totalAmount, - 'transaction_uuid' => $query->transactionUuid, - ] - ); - - return TransactionStatusPayload::fromArray($payload)->toResult(); + $this->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 @@ + */ + 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 @@ +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, @@ -41,6 +50,35 @@ public function verify(CallbackPayload $payload, ?VerificationExpectation $conte $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); } @@ -64,7 +102,22 @@ private function assertConsistent( } 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/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/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/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; + } +} From 3d1f04a0922aa13e1b41cc64023bbe27ec8aadc8 Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Fri, 20 Feb 2026 22:23:53 +0545 Subject: [PATCH 09/10] feat(dx): simplify client bootstrap with EsewaPayment::make --- README.md | 37 ++++++++++++++++++++++++++------- src/EsewaPayment.php | 20 ++++++++++++++++++ tests/Unit/EsewaPaymentTest.php | 28 +++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/EsewaPaymentTest.php diff --git a/README.md b/README.md index 8afd49e..87da880 100644 --- a/README.md +++ b/README.md @@ -55,23 +55,42 @@ declare(strict_types=1); use EsewaPayment\Client\EsewaClient; use EsewaPayment\Config\ClientOptions; -use EsewaPayment\Config\GatewayConfig; +use EsewaPayment\EsewaPayment; use EsewaPayment\Infrastructure\Idempotency\InMemoryIdempotencyStore; use EsewaPayment\Infrastructure\Transport\Psr18Transport; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Log\NullLogger; use Symfony\Component\HttpClient\Psr18Client; -$config = GatewayConfig::make( +$client = EsewaPayment::make( merchantCode: 'EPAYTEST', secretKey: $_ENV['ESEWA_SECRET_KEY'], + transport: new Psr18Transport(new Psr18Client(), new Psr17Factory()), environment: 'uat', // uat|test|sandbox|production|prod|live ); +``` + +Add hardening options only when needed: + +```php +assertInstanceOf(EsewaClient::class, $client); + $this->assertNotNull($client->checkout()); + $this->assertNotNull($client->callbacks()); + $this->assertNotNull($client->transactions()); + } +} From 67416fdc34fb8ebdeb902b3e370fbbe07271eb14 Mon Sep 17 00:00:00 2001 From: Sujip Thapa Date: Fri, 20 Feb 2026 22:26:26 +0545 Subject: [PATCH 10/10] docs(readme): add secure Laravel integration and support matrix --- README.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 156 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 87da880..7c5bfe5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Framework-agnostic eSewa ePay v2 SDK for modern PHP applications. - [Transaction Status Flow](#transaction-status-flow) - [Configuration Patterns](#configuration-patterns) - [Production Hardening](#production-hardening) -- [Framework Integration Examples](#framework-integration-examples) +- [Laravel Integration (Secure)](#laravel-integration-secure) - [Custom Transport and Testing](#custom-transport-and-testing) - [Error Handling](#error-handling) - [Development](#development) @@ -300,32 +300,176 @@ Emitted event keys (via log context): - `esewa.callback.replay_detected` - `esewa.callback.verified` -## Framework Integration Examples +## Laravel Integration (Secure) -### Laravel service container binding +### 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(EsewaClient::class, function () { - $config = GatewayConfig::make( - merchantCode: config('services.esewa.merchant_code'), - secretKey: config('services.esewa.secret_key'), - environment: config('services.esewa.environment', 'uat'), - ); +$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( - $config, - new Psr18Transport(new Psr18Client(), new Psr17Factory()) + 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), + ), ); }); ``` -### Symfony-style service wiring +### 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(), + ]); + } +} +``` + +`resources/views/payments/esewa/redirect.blade.php`: + +```blade +
+ @foreach ($fields as $name => $value) + + @endforeach +
+ + +``` + +### 4) Callback controller with strict verification + +```php +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); + } + + // Optional: double-check status endpoint before marking paid. + // $status = $esewa->transactions()->fetchStatus(...); + + // Mark paid exactly once (idempotent DB update). + // dispatch(new FulfillOrderJob($order->id)); + + return response('ok', 200); + } +} +``` + +### 5) Security checklist (Laravel) + +- 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 + +### Framework usage outside Laravel - Register `GatewayConfig` as a service parameter object - Inject `EsewaClient` into controllers/services