diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b76aba..59f91c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,15 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4', '8.5'] - dependency-version: ['prefer-stable', 'prefer-lowest'] + include: + - php: '8.2' + dependency-version: 'prefer-lowest' + - php: '8.2' + dependency-version: 'prefer-stable' + - php: '8.3' + dependency-version: 'prefer-stable' + - php: '8.4' + dependency-version: 'prefer-stable' steps: - name: Checkout code diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fd5449..0f5a4f2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,27 +2,50 @@ ## Overview -`Sujip\Esewa` is a framework-agnostic eSewa ePay v2 SDK focused on checkout payload generation, callback verification, and transaction status validation. -The package follows a domain-oriented structure with clear service boundaries and pluggable transport/idempotency contracts. +The package is split into a framework-agnostic core SDK and an Omnipay bridge that sits on top of it. + +The public namespaces are: + +- `Sujip\\Esewa` for the framework-agnostic client +- `Omnipay\\Esewa` for the Omnipay bridge + +The design is intentionally conservative: + +- payment rules live in one place +- transport and storage stay replaceable +- callback verification stays deterministic +- the core stays zero-dependency by default + +## Design choices + +1. Core payment flows use dedicated models instead of loose arrays. +2. Money and identifiers are normalized early, so validation is not scattered. +3. The default runtime works with built-in pieces. +4. Callback verification and replay handling are part of the package, not left to application code. +5. Transport, clock, retry policy, and replay storage can be replaced without rewriting the domain layer. ## Project Map ```text src/ - EsewaPayment.php + Esewa.php Static entry point + Client/ + EsewaClient.php Root client + CheckoutService.php Checkout workflow + CallbackService.php Callback workflow + TransactionService.php Status workflow Config/ - GatewayConfig.php Gateway credentials + runtime options - EndpointResolver.php UAT/production endpoint resolution + GatewayConfig.php Credentials and endpoint settings + EndpointResolver.php UAT/production endpoint resolution Environment.php - ClientOptions.php - Client/ - EsewaClient.php Root client exposing service modules - CheckoutService.php Checkout/form payload workflow - CallbackService.php Callback decode + verification workflow - TransactionService.php Status check workflow - Service/ - SignatureService.php HMAC signature generation/verification - CallbackVerifier.php Anti-fraud + callback validation rules + ClientOptions.php Runtime options, retry policy, clock, replay config + Contracts/ + Arrayable.php + Hydratable.php + ClockInterface.php + RetryPolicyInterface.php + TransportInterface.php + IdempotencyStoreInterface.php Domain/ Checkout/ CheckoutRequest.php @@ -31,79 +54,201 @@ src/ Verification/ CallbackPayload.php CallbackData.php - CallbackVerification.php VerificationExpectation.php + CallbackVerification.php + VerificationState.php Transaction/ TransactionStatusRequest.php TransactionStatusPayload.php TransactionStatus.php PaymentStatus.php - Contracts/ - TransportInterface.php - IdempotencyStoreInterface.php + ValueObject/ + Amount.php + TransactionUuid.php + ProductCode.php + ReferenceId.php + Service/ + SignatureService.php + CallbackVerifier.php Infrastructure/ - Transport/Psr18Transport.php - Idempotency/InMemoryIdempotencyStore.php - Idempotency/NullIdempotencyStore.php - Exception/ - EsewaException.php - SignatureException.php - InvalidPayloadException.php - FraudValidationException.php - ApiErrorException.php - TransportException.php -tests/ - Unit/* - Fakes/FakeTransport.php - Fakes/FlakyTransport.php - Fakes/ArrayLogger.php + Transport/ + CurlTransport.php + Psr18Transport.php + Idempotency/ + InMemoryIdempotencyStore.php + NullIdempotencyStore.php + FilesystemIdempotencyStore.php + PdoIdempotencyStore.php + Support/ + SystemClock.php + FixedDelayRetryPolicy.php + Omnipay/ + SecureGateway.php + Message/* ``` -## Runtime Flows +## Layer breakdown + +### 1. Entry Layer + +`Esewa` is the convenience bootstrap. + +Responsibilities: + +- build `GatewayConfig` +- choose the transport +- construct `EsewaClient` + +### 2. Client Layer + +`EsewaClient` exposes the three functional modules: + +- `checkout()` +- `callbacks()` +- `transactions()` + +The client is intentionally thin. It wires the pieces together and leaves the actual rules to the domain and service layers. + +### 3. Domain Layer + +The domain layer is where most of the package behavior lives. + +Responsibilities: + +- validate request shape +- normalize primitives into value objects +- convert to and from arrays +- expose explicit result types + +Examples: + +- `CheckoutRequest` models a full checkout intent input +- `VerificationExpectation` models anti-fraud comparison context +- `TransactionStatusRequest` models reconciliation queries +- `CallbackVerification` models callback outcome with explicit `VerificationState` + +### 4. Value Object Layer + +Value objects exist to stop the usual payment bugs caused by passing strings around everywhere. + +Current value objects: + +- `Amount` +- `TransactionUuid` +- `ProductCode` +- `ReferenceId` + +This keeps formatting rules and basic validation in one place. + +### 5. Service Layer + +The service layer holds logic that should not be duplicated across controllers or adapters. + +- `SignatureService` generates and verifies signatures +- `CallbackVerifier` enforces signature validation, expectation matching, and replay protection + +### 6. Infrastructure Layer + +Infrastructure concerns stay behind contracts. + +Transport: + +- `CurlTransport` for the zero-dependency runtime path +- `Psr18Transport` for optional PSR-18 integration + +Replay protection stores: + +- `NullIdempotencyStore` +- `InMemoryIdempotencyStore` +- `FilesystemIdempotencyStore` +- `PdoIdempotencyStore` + +### 7. Support Layer + +Support abstractions keep time and retry behavior deterministic. + +- `SystemClock` +- `FixedDelayRetryPolicy` + +These are deliberately small. Their job is to keep time-dependent and retry-dependent logic testable. + +## Runtime flows + +### Checkout Flow + +1. Build `CheckoutRequest` +2. `CheckoutService` computes total amount and signature +3. `CheckoutPayload` is created as a typed form payload +4. `CheckoutIntent` exposes action URL and form fields + +### Callback Verification Flow + +1. Build `CallbackPayload` +2. Decode into `CallbackData` +3. Verify signature with `SignatureService` +4. Validate expected values with `VerificationExpectation` +5. Check replay protection through `IdempotencyStoreInterface` +6. Return `CallbackVerification` with an explicit `VerificationState` + +### Transaction Status Flow + +1. Build `TransactionStatusRequest` +2. `TransactionService` performs the status request through `TransportInterface` +3. Retry behavior is delegated to `RetryPolicyInterface` +4. API payload is mapped to `TransactionStatusPayload` +5. Domain result is returned as `TransactionStatus` + +## Model pattern + +The model pattern here is intentionally simple: + +- request models support `fromArray()` and `toArray()` +- result models expose typed fields and `toArray()` +- value objects encapsulate primitive validation + +That keeps the SDK easy to use from plain PHP, while still making it easier to cross framework boundaries cleanly. + +## Extensibility points + +Transport: + +- implement `TransportInterface` + +Replay protection: -### 1) Checkout Flow +- implement `IdempotencyStoreInterface` -1. Build client via `EsewaPayment` using `GatewayConfig`. -2. `CheckoutService` validates request + options. -3. `SignatureService` signs required fields. -4. Service returns `CheckoutPayload` for HTML form redirect/post. +Time: -### 2) Callback Verification Flow +- implement `ClockInterface` -1. Callback payload is decoded into `CallbackPayload`/`CallbackData`. -2. `CallbackVerifier` validates signature and anti-fraud expectations. -3. `CallbackService` returns structured `CallbackVerification`. -4. Optional idempotency guard/store prevents duplicate side effects. +Retry behavior: -### 3) Transaction Status Flow +- implement `RetryPolicyInterface` -1. `TransactionService` sends status request using `TransportInterface`. -2. Payload is mapped into `TransactionStatusPayload` and `TransactionStatus` enums. -3. Caller applies business policy based on typed status. +That gives applications room to swap in their own transport or persistence choices without adding mandatory packages to the core. -## Security Model +## Omnipay Bridge -- Signature validation is centralized in `SignatureService`. -- Callback validation enforces expectation checks (amount/product/uuid/reference). -- Fraud mismatches raise `FraudValidationException`. -- Invalid callback payloads raise `InvalidPayloadException`. -- Transport/API failures are isolated in typed exceptions. +`Omnipay\\Esewa` is kept separate on purpose. -## Extension Points +Bridge responsibilities: -- Implement `TransportInterface` to integrate any HTTP client. -- Implement `IdempotencyStoreInterface` for Redis/DB-backed duplicate protection. -- Extend domain models and mapping rules without coupling to framework code. +- adapt Omnipay request/response expectations +- reuse the same core domain and service logic +- avoid maintaining two different sets of payment rules -## Testing Strategy +## Testing -- Unit tests cover checkout, callback, transaction, endpoint resolution, and production hardening paths. -- Fake transports provide deterministic network behavior. -- Flaky transport tests validate failure handling. +The current test suite covers: -## Contributor Notes +- domain validation +- checkout payload generation +- callback signature verification +- replay protection behavior +- retry policy behavior +- persistent idempotency stores +- Omnipay bridge behavior +- static analysis across the full source tree -- Keep core package framework-agnostic. -- Preserve public API stability; prefer additive changes. -- Add tests and README updates for behavior changes. -- Keep configuration explicit and secure by default. +The point of that coverage is simple: payment verification and reconciliation code should stay boring, predictable, and hard to regress. diff --git a/README.md b/README.md index 0719cf6..9f9f691 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,67 @@ -# EsewaPayment PHP SDK +# eSewa PHP SDK -Framework-agnostic eSewa ePay v2 SDK for modern PHP applications. +Framework-agnostic, zero-dependency PHP SDK for eSewa ePay v2, with an optional Omnipay v3 bridge. -[![CI](https://github.com/sudiptpa/esewa/actions/workflows/ci.yml/badge.svg)](https://github.com/sudiptpa/esewa/actions/workflows/ci.yml) -[![Latest Release](https://img.shields.io/github/v/release/sudiptpa/esewa?sort=semver)](https://github.com/sudiptpa/esewa/releases) -[![GitHub Downloads](https://img.shields.io/github/downloads/sudiptpa/esewa/total)](https://github.com/sudiptpa/esewa/releases) -[![PHP Version](https://img.shields.io/badge/php-8.1--8.5-777bb4.svg)](https://www.php.net/) -[![Packagist](https://img.shields.io/badge/packagist-sudiptpa%2Fesewa--payment-f28d1a.svg)](https://packagist.org/packages/sudiptpa/esewa-payment) -[![License](https://img.shields.io/github/license/sudiptpa/esewa)](LICENSE) +[![CI](https://github.com/sudiptpa/esewa-sdk-php/actions/workflows/ci.yml/badge.svg)](https://github.com/sudiptpa/esewa-sdk-php/actions/workflows/ci.yml) +[![Latest Release](https://img.shields.io/github/v/release/sudiptpa/esewa-sdk-php?sort=semver)](https://github.com/sudiptpa/esewa-sdk-php/releases) +[![GitHub Downloads](https://img.shields.io/github/downloads/sudiptpa/esewa-sdk-php/total)](https://github.com/sudiptpa/esewa-sdk-php/releases) +[![PHP Version](https://img.shields.io/badge/php-8.2--8.5-777bb4.svg)](https://www.php.net/) +[![Packagist](https://img.shields.io/badge/packagist-sudiptpa%2Fomnipay--esewa-f28d1a.svg)](https://packagist.org/packages/sudiptpa/omnipay-esewa) +[![License](https://img.shields.io/github/license/sudiptpa/esewa-sdk-php)](LICENSE) + +## Public API + +This package exposes two public namespaces: + +- `Sujip\\Esewa` +- `Omnipay\\Esewa` ## Highlights -- ePay v2 checkout intent generation (`/v2/form`) -- HMAC-SHA256 + base64 signature generation and verification -- Callback verification with anti-fraud field consistency checks -- Status check API support with typed status mapping -- Model-first request/payload/result objects -- PSR-18 transport integration -- PHP `8.1` to `8.5` support -- PHPUnit + PHPStan + CI matrix - -## Table of Contents - -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Core API Shape](#core-api-shape) -- [Checkout Flow](#checkout-flow) -- [Callback Verification Flow](#callback-verification-flow) -- [Transaction Status Flow](#transaction-status-flow) -- [Configuration Patterns](#configuration-patterns) -- [Production Hardening](#production-hardening) -- [Laravel Integration (Secure)](#laravel-integration-secure) -- [Custom Transport and Testing](#custom-transport-and-testing) -- [Error Handling](#error-handling) -- [Development](#development) +- checkout, callback, and transaction flows built around request and result models +- typed value objects for amount, transaction UUID, product code, and reference ID +- `toArray()` and `fromArray()` where they are useful +- callback verification with explicit states: `verified`, `invalid_signature`, `replayed` +- zero-dependency core with built-in `CurlTransport` +- configurable retry policy and clock handling +- replay protection backed by filesystem or PDO storage +- optional PSR-18 transport support +- optional Omnipay v3 bridge +- PHP `8.2` to `8.5` ## Installation ```bash -composer require sudiptpa/esewa-payment +composer require sudiptpa/omnipay-esewa ``` -For PSR-18 usage examples: +Optional PSR-18 usage: ```bash composer require symfony/http-client nyholm/psr7 ``` -## Quick Start - -```php -checkout()` -- `$client->callbacks()` -- `$client->transactions()` - -Primary model objects: - -- `CheckoutRequest` -- `CheckoutIntent` -- `CallbackPayload` -- `VerificationExpectation` -- `CallbackVerification` -- `TransactionStatusRequest` -- `TransactionStatus` - -Static convenience entry point: - -```php -use EsewaPayment\EsewaPayment; - -$client = EsewaPayment::make( - merchantCode: 'EPAYTEST', - secretKey: 'secret', - transport: $transport, -); -``` - -## Checkout Flow - -### 1) Build a checkout intent - -```php -use EsewaPayment\Domain\Checkout\CheckoutRequest; $intent = $client->checkout()->createIntent(new CheckoutRequest( amount: '100', @@ -151,72 +75,78 @@ $intent = $client->checkout()->createIntent(new CheckoutRequest( )); ``` -### 2) Render form fields in plain PHP +The request constructor accepts strings for convenience. Internally, those values are normalized into value objects. If you want stricter typing in your own code, build the value objects directly: ```php -$form = $intent->form(); +use Sujip\Esewa\Domain\Checkout\CheckoutRequest; +use Sujip\Esewa\ValueObject\Amount; +use Sujip\Esewa\ValueObject\ProductCode; +use Sujip\Esewa\ValueObject\TransactionUuid; -echo '
'; - -foreach ($form['fields'] as $name => $value) { - echo ''; -} - -echo ''; -echo '
'; +$request = new CheckoutRequest( + amount: Amount::fromString('100'), + taxAmount: Amount::fromString('0'), + serviceCharge: Amount::fromString('0'), + deliveryCharge: Amount::fromString('0'), + transactionUuid: TransactionUuid::fromString('TXN-1001'), + productCode: ProductCode::fromString('EPAYTEST'), + successUrl: 'https://merchant.example.com/esewa/success', + failureUrl: 'https://merchant.example.com/esewa/failure', +); ``` -### 3) Get fields directly as array +## Working With Models + +The core models can be converted to arrays. That is mainly useful when you are crossing controller boundaries, queueing work, or saving fixtures for tests. ```php -$fields = $intent->fields(); // array +$payload = $request->toArray(); +$restored = CheckoutRequest::fromArray($payload); ``` -## Callback Verification Flow +The client stays small. Most integrations only need three modules: -Never trust redirect success alone. Always verify callback payload and signature. +- `$client->checkout()` +- `$client->callbacks()` +- `$client->transactions()` -### 1) Build payload from callback request +## Callback Verification ```php -use EsewaPayment\Domain\Verification\CallbackPayload; +use Sujip\Esewa\Domain\Verification\CallbackPayload; +use Sujip\Esewa\Domain\Verification\VerificationExpectation; $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( +$result = $client->callbacks()->verifyCallback( $payload, new VerificationExpectation( totalAmount: '100.00', transactionUuid: 'TXN-1001', productCode: 'EPAYTEST', - referenceId: null, // optional ) ); -if (!$verification->valid || !$verification->isSuccessful()) { - // reject +if ($result->state->value === 'replayed') { + http_response_code(409); + exit('Replay detected'); } -``` -### 3) Verify without context (signature only) - -```php -$verification = $client->callbacks()->verifyCallback($payload); +if (!$result->isSuccessful()) { + http_response_code(400); + exit('Invalid callback'); +} ``` -## Transaction Status Flow +Do not treat the success redirect alone as proof of payment. Verify the callback on your backend and keep a status check as a fallback when something looks off. + +## Transaction Status ```php -use EsewaPayment\Domain\Transaction\TransactionStatusRequest; +use Sujip\Esewa\Domain\Transaction\TransactionStatusRequest; $status = $client->transactions()->fetchStatus(new TransactionStatusRequest( transactionUuid: 'TXN-1001', @@ -225,308 +155,63 @@ $status = $client->transactions()->fetchStatus(new TransactionStatusRequest( )); if ($status->isSuccessful()) { - // COMPLETE + // mark paid } - -echo $status->status->value; // PENDING|COMPLETE|FULL_REFUND|PARTIAL_REFUND|AMBIGUOUS|NOT_FOUND|CANCELED|UNKNOWN ``` -## Configuration Patterns +## Production Notes -### Environment aliases - -- UAT: `uat`, `test`, `sandbox` -- Production: `production`, `prod`, `live` - -### Endpoint overrides - -Useful if eSewa documentation/endpoints differ by account region or rollout: +For live traffic, turn on replay protection and use persistent storage: ```php -$config = GatewayConfig::make( +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Esewa; +use Sujip\Esewa\Infrastructure\Idempotency\FilesystemIdempotencyStore; + +$client = Esewa::make( merchantCode: 'EPAYTEST', - secretKey: 'secret', + secretKey: $_ENV['ESEWA_SECRET_KEY'], environment: 'uat', - checkoutFormUrl: 'https://custom-esewa.example/api/epay/main/v2/form', - statusCheckUrl: 'https://custom-esewa.example/api/epay/transaction/status/', -); -``` - -## Production Hardening - -### Retry policy for status checks - -`fetchStatus()` retries `TransportException` failures based on `ClientOptions`: - -```php -new ClientOptions( - maxStatusRetries: 2, // total additional retry attempts - statusRetryDelayMs: 150, + options: new ClientOptions( + preventCallbackReplay: true, + idempotencyStore: new FilesystemIdempotencyStore(__DIR__ . '/storage/esewa-idempotency'), + ), ); ``` -### Callback replay protection (idempotency) - -Enable replay protection with an idempotency store: +Retry behavior is also configurable: ```php -use EsewaPayment\Config\ClientOptions; -use EsewaPayment\Infrastructure\Idempotency\InMemoryIdempotencyStore; +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Support\FixedDelayRetryPolicy; $options = new ClientOptions( - preventCallbackReplay: true, - idempotencyStore: new InMemoryIdempotencyStore(), + retryPolicy: new FixedDelayRetryPolicy( + maxRetries: 3, + delayUs: 250000, + ), ); ``` -For production, implement `IdempotencyStoreInterface` with shared storage (Redis, DB, etc.) instead of in-memory storage. - -### Logging hooks - -Provide any PSR-3 logger in `ClientOptions`: +## Omnipay Bridge ```php -use Psr\Log\LoggerInterface; +use Omnipay\Esewa\SecureGateway; -$options = new ClientOptions(logger: $logger); // $logger is LoggerInterface +$gateway = new SecureGateway(); +$gateway->setMerchantCode('EPAYTEST'); +$gateway->setSecretKey($_ENV['ESEWA_SECRET_KEY']); +$gateway->setProductCode('EPAYTEST'); +$gateway->setTestMode(true); +$gateway->setReturnUrl('https://merchant.example.com/esewa/success'); +$gateway->setFailureUrl('https://merchant.example.com/esewa/failure'); ``` -Emitted event keys (via log context): - -- `esewa.status.started` -- `esewa.status.retry` -- `esewa.status.completed` -- `esewa.status.failed` -- `esewa.callback.invalid_signature` -- `esewa.callback.replay_detected` -- `esewa.callback.verified` - -## Laravel Integration (Secure) - -### Supported Laravel Versions - -This package is framework-agnostic and supports PHP `8.1` to `8.5`. - -Laravel support is therefore: - -- Laravel `10.x`, `11.x`, `12.x` when your app runtime is PHP `8.1` to `8.5` -- Other Laravel versions may work if they run on supported PHP and PSR dependencies, but are not the primary target matrix - -### 1) Service container binding (single client, production options) - -Create a provider (for example `app/Providers/EsewaServiceProvider.php`): - -```php -use EsewaPayment\Client\EsewaClient; -use EsewaPayment\Config\ClientOptions; -use EsewaPayment\Config\GatewayConfig; -use EsewaPayment\Contracts\IdempotencyStoreInterface; -use EsewaPayment\Infrastructure\Idempotency\InMemoryIdempotencyStore; -use EsewaPayment\Infrastructure\Transport\Psr18Transport; -use Nyholm\Psr7\Factory\Psr17Factory; -use Symfony\Component\HttpClient\Psr18Client; - -$this->app->singleton(IdempotencyStoreInterface::class, function () { - // Replace with Redis/DB-backed implementation for multi-server production. - return new InMemoryIdempotencyStore(); -}); - -$this->app->singleton(EsewaClient::class, function ($app) { - return new EsewaClient( - GatewayConfig::make( - merchantCode: config('services.esewa.merchant_code'), - secretKey: config('services.esewa.secret_key'), - environment: config('services.esewa.environment', 'uat'), - ), - new Psr18Transport(new Psr18Client(), new Psr17Factory()), - new ClientOptions( - maxStatusRetries: 2, - statusRetryDelayMs: 150, - preventCallbackReplay: true, - idempotencyStore: $app->make(IdempotencyStoreInterface::class), - logger: $app->make(\Psr\Log\LoggerInterface::class), - ), - ); -}); -``` +Supported bridge flows: -### 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 -- 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 -} -``` +- `purchase()` +- `completePurchase()` +- `verifyPayment()` ## Development @@ -535,20 +220,3 @@ composer test composer stan composer rector:check ``` - -## Author - -- Sujip Thapa () - -## Contributing - -Contributions are welcome. - -If you would like to contribute: - -1. Fork the repository and create a feature branch. -2. Add or update tests for your change. -3. Run quality checks locally (`composer test`, `composer stan`). -4. Open a pull request with a clear description. - -Bug reports, security hardening ideas, docs improvements, and real-world integration examples are all appreciated. diff --git a/composer.json b/composer.json index 0fcf63e..c8f4152 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "sudiptpa/esewa-payment", + "name": "sudiptpa/omnipay-esewa", "type": "library", "description": "Framework-agnostic eSewa ePay v2 payment SDK for PHP.", "keywords": [ @@ -17,33 +17,29 @@ } ], "require": { - "php": ">=8.1 <8.6", - "psr/http-client": "^1.0", - "psr/http-factory": "^1.1", - "psr/http-message": "^2.0", - "psr/log": "^1.1 || ^2.0 || ^3.0" + "php": ">=8.2 <8.6" }, "require-dev": { + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", "phpunit/phpunit": "^10.5 || ^11.0", "phpstan/phpstan": "^1.11", "rector/rector": "^1.2", "symfony/http-client": "^6.4 || ^7.0 || ^8.0", "nyholm/psr7": "^1.8" }, - "suggest": { - "sudiptpa/omnipay-esewa": "Legacy package name. This SDK has moved to sudiptpa/esewa-payment." - }, "conflict": { - "sudiptpa/omnipay-esewa": "*" + "sudiptpa/esewa-php-sdk": "*" }, "autoload": { "psr-4": { - "EsewaPayment\\": "src/" + "Sujip\\Esewa\\": "src/", + "Omnipay\\Esewa\\": "src/Omnipay/" } }, "autoload-dev": { "psr-4": { - "EsewaPayment\\Tests\\": "tests/" + "Sujip\\Esewa\\Tests\\": "tests/" } }, "extra": { diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..71542df --- /dev/null +++ b/composer.lock @@ -0,0 +1,2583 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "5cdeb507e5d7f3d6227512700bc599d8", + "packages": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.12.33", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-02-28T20:30:03+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "rector/rector", + "version": "1.2.10", + "source": { + "type": "git", + "url": "https://github.com/rectorphp/rector.git", + "reference": "40f9cf38c05296bd32f444121336a521a293fa61" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/40f9cf38c05296bd32f444121336a521a293fa61", + "reference": "40f9cf38c05296bd32f444121336a521a293fa61", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "phpstan/phpstan": "^1.12.5" + }, + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" + }, + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" + }, + "bin": [ + "bin/rector" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" + ], + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/1.2.10" + }, + "funding": [ + { + "url": "https://github.com/tomasvotruba", + "type": "github" + } + ], + "time": "2024-11-08T13:59:10+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/http-client", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "ade9bd433450382f0af154661fc8e72758b4de36" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/ade9bd433450382f0af154661fc8e72758b4de36", + "reference": "ade9bd433450382f0af154661fc8e72758b4de36", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<3", + "php-http/discovery": "<1.15" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^5.3.2", + "amphp/http-tunnel": "^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:40+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-29T11:18:49+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.2 <8.6" + }, + "platform-dev": [], + "plugin-api-version": "2.2.0" +} diff --git a/docs/migration-guide.md b/docs/migration-guide.md new file mode 100644 index 0000000..6897bfd --- /dev/null +++ b/docs/migration-guide.md @@ -0,0 +1,139 @@ +# Migration Guide + +## What changed + +The package now leans on request and result models instead of passing loose arrays and strings through every layer. + +The main differences are: + +- checkout, callback, and status flows are centered around domain models +- request models support `toArray()` and `fromArray()` +- core identifiers and money values are normalized into typed value objects +- callback verification returns an explicit state +- retry behavior can be configured as a policy +- replay protection can use persistent zero-dependency storage + +## Public Namespaces + +- framework-agnostic client: `Sujip\\Esewa` +- Omnipay bridge: `Omnipay\\Esewa` + +## Framework-Agnostic Client Migration + +### Earlier style + +Older integrations mostly passed strings around and treated request objects as thin containers. + +### Current style + +You can still pass strings to the constructors. The difference is that the models now normalize and validate those values more aggressively. + +```php +use Sujip\Esewa\Domain\Checkout\CheckoutRequest; +use Sujip\Esewa\Esewa; + +$client = Esewa::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', +); + +$request = 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', +); +``` + +If you want stricter typing in your own code: + +```php +use Sujip\Esewa\ValueObject\Amount; +use Sujip\Esewa\ValueObject\ProductCode; +use Sujip\Esewa\ValueObject\TransactionUuid; + +$request = new CheckoutRequest( + amount: Amount::fromString('100'), + taxAmount: Amount::fromString('0'), + serviceCharge: Amount::fromString('0'), + deliveryCharge: Amount::fromString('0'), + transactionUuid: TransactionUuid::fromString('TXN-1001'), + productCode: ProductCode::fromString('EPAYTEST'), + successUrl: 'https://merchant.example.com/esewa/success', + failureUrl: 'https://merchant.example.com/esewa/failure', +); +``` + +## Model conversion + +Models support array conversion, which is useful at controller boundaries, in queued jobs, and in test fixtures. + +```php +$payload = $request->toArray(); +$restored = CheckoutRequest::fromArray($payload); +``` + +The same pattern applies to: + +- `VerificationExpectation` +- `TransactionStatusRequest` +- `CallbackPayload` +- `TransactionStatusPayload` + +## Callback verification + +Verification now returns a richer result object. + +```php +$result = $client->callbacks()->verifyCallback($payload, $expectation); +``` + +Available states: + +- `verified` +- `invalid_signature` +- `replayed` + +That makes it easier to tell a bad signature from a replayed callback. + +## Retry and replay protection + +Retry behavior is no longer limited to a couple of scalar options. You can provide a policy object instead. + +```php +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Support\FixedDelayRetryPolicy; + +$options = new ClientOptions( + retryPolicy: new FixedDelayRetryPolicy(maxRetries: 3, delayUs: 250000), +); +``` + +Replay protection can use persistent storage: + +- `FilesystemIdempotencyStore` +- `PdoIdempotencyStore` + +## Omnipay migration + +The bridge remains under `Omnipay\\Esewa`. + +Supported methods: + +- `purchase()` +- `completePurchase()` +- `verifyPayment()` + +## Production checklist + +1. Keep `merchantCode` and `secretKey` in environment configuration. +2. Verify callbacks on the backend before fulfillment. +3. Compare verified values against stored order state. +4. Use filesystem or PDO-backed replay protection in production. +5. Reconcile uncertain states with transaction status checks. +6. Treat redirect success as a user-facing signal, not as proof of payment. diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..5cb442e --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,287 @@ +# User Guide + +There are two ways to use this package: + +- `Sujip\\Esewa` for the framework-agnostic client +- `Omnipay\\Esewa` for the Omnipay bridge + +## 1. Framework-Agnostic Client + +### Create a client + +```php +toArray(); +$restored = CheckoutRequest::fromArray($payload); +``` + +That is useful for: + +- controller boundaries +- queue payloads +- config-driven integrations +- fixtures and tests + +### Create a checkout intent + +```php +$intent = $client->checkout()->createIntent($request); +$form = $intent->form(); +``` + +### Verify callbacks + +```php +use Sujip\Esewa\Domain\Verification\CallbackPayload; +use Sujip\Esewa\Domain\Verification\VerificationExpectation; + +$payload = CallbackPayload::fromArray([ + 'data' => $_GET['data'] ?? '', + 'signature' => $_GET['signature'] ?? '', +]); + +$result = $client->callbacks()->verifyCallback( + $payload, + new VerificationExpectation( + totalAmount: '100.00', + transactionUuid: 'TXN-1001', + productCode: 'EPAYTEST', + ) +); +``` + +### Handle verification states + +```php +switch ($result->state->value) { + case 'verified': + // continue + break; + + case 'replayed': + http_response_code(409); + exit('Replay detected'); + + case 'invalid_signature': + http_response_code(400); + exit('Invalid signature'); +} +``` + +### Enable replay protection + +For production, use persistent storage. The SDK ships with these store options: + +- `FilesystemIdempotencyStore` +- `PdoIdempotencyStore` +- `InMemoryIdempotencyStore` for tests + +Filesystem example: + +```php +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Infrastructure\Idempotency\FilesystemIdempotencyStore; + +$client = Esewa::make( + merchantCode: 'EPAYTEST', + secretKey: $_ENV['ESEWA_SECRET_KEY'], + environment: 'uat', + options: new ClientOptions( + preventCallbackReplay: true, + idempotencyStore: new FilesystemIdempotencyStore(__DIR__ . '/storage/esewa-idempotency'), + ) +); +``` + +PDO example: + +```php +use PDO; +use Sujip\Esewa\Infrastructure\Idempotency\PdoIdempotencyStore; + +$pdo = new PDO('sqlite:' . __DIR__ . '/storage/esewa.sqlite'); +$store = new PdoIdempotencyStore($pdo); +``` + +### Reconcile transaction state + +```php +use Sujip\Esewa\Domain\Transaction\TransactionStatusRequest; + +$status = $client->transactions()->fetchStatus(new TransactionStatusRequest( + transactionUuid: 'TXN-1001', + totalAmount: '100.00', + productCode: 'EPAYTEST', +)); +``` + +### Customize retries + +```php +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Support\FixedDelayRetryPolicy; + +$options = new ClientOptions( + retryPolicy: new FixedDelayRetryPolicy( + maxRetries: 3, + delayUs: 250000, + ), +); +``` + +### Use a custom transport + +The default transport is `CurlTransport`, so the core stays zero-dependency at runtime. If your application already has a PSR-18 client, you can swap it in. + +```php +use Nyholm\Psr7\Factory\Psr17Factory; +use Sujip\Esewa\Infrastructure\Transport\Psr18Transport; +use Symfony\Component\HttpClient\Psr18Client; + +$client = Esewa::make( + merchantCode: 'EPAYTEST', + secretKey: $_ENV['ESEWA_SECRET_KEY'], + transport: new Psr18Transport(new Psr18Client(), new Psr17Factory()), + environment: 'uat', +); +``` + +## 2. Omnipay Bridge + +Use the bridge if the rest of your payment layer already follows Omnipay conventions. + +### Create the gateway + +```php +use Omnipay\Esewa\SecureGateway; + +$gateway = new SecureGateway(); +$gateway->setMerchantCode('EPAYTEST'); +$gateway->setSecretKey($_ENV['ESEWA_SECRET_KEY']); +$gateway->setProductCode('EPAYTEST'); +$gateway->setTaxAmount('0'); +$gateway->setServiceCharge('0'); +$gateway->setDeliveryCharge('0'); +$gateway->setTestMode(true); +$gateway->setReturnUrl('https://merchant.example.com/esewa/success'); +$gateway->setFailureUrl('https://merchant.example.com/esewa/failure'); +``` + +### Purchase flow + +```php +$response = $gateway->purchase([ + 'amount' => '100', + 'transactionId' => 'TXN-1001', +])->send(); + +if ($response->isRedirect()) { + $redirectUrl = $response->getRedirectUrl(); + $fields = $response->getRedirectData(); +} +``` + +### Complete purchase + +```php +$response = $gateway->completePurchase([ + 'transactionUuid' => 'TXN-1001', + 'totalAmount' => '100.00', + 'referenceNumber' => 'REF-1001', +])->send(); + +if ($response->isSuccessful()) { + $reference = $response->getTransactionReference(); +} +``` + +### Verify payment + +```php +$response = $gateway->verifyPayment([ + 'amount' => '100.00', + 'transactionId' => 'TXN-1001', +])->send(); + +if ($response->isSuccessful()) { + $referenceId = $response->getReferenceId(); +} +``` + +## 3. Production Guidance + +1. Keep secrets outside source control. +2. Verify callbacks on the server before fulfillment. +3. Compare verified values with your stored order state. +4. Use persistent replay protection in production. +5. Treat redirect success as customer UX, not payment proof. +6. Reconcile ambiguous states with transaction status checks. + +The common mistake here is trusting the browser redirect too early. eSewa can redirect a user back while your application still needs to verify the callback or reconcile the final state. + +## 4. Error Handling + +Core exceptions: + +- `Sujip\\Esewa\\Exception\\EsewaException` +- `Sujip\\Esewa\\Exception\\InvalidPayloadException` +- `Sujip\\Esewa\\Exception\\SignatureException` +- `Sujip\\Esewa\\Exception\\FraudValidationException` +- `Sujip\\Esewa\\Exception\\TransportException` +- `Sujip\\Esewa\\Exception\\ApiErrorException` diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e8ed43a..4d05e59 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - + - + tests diff --git a/src/Client/CallbackService.php b/src/Client/CallbackService.php index 2073d78..ce6c90c 100644 --- a/src/Client/CallbackService.php +++ b/src/Client/CallbackService.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace EsewaPayment\Client; +namespace Sujip\Esewa\Client; -use EsewaPayment\Domain\Verification\CallbackPayload; -use EsewaPayment\Domain\Verification\CallbackVerification; -use EsewaPayment\Domain\Verification\VerificationExpectation; -use EsewaPayment\Service\CallbackVerifier; +use Sujip\Esewa\Domain\Verification\CallbackPayload; +use Sujip\Esewa\Domain\Verification\CallbackVerification; +use Sujip\Esewa\Domain\Verification\VerificationExpectation; +use Sujip\Esewa\Service\CallbackVerifier; final class CallbackService { diff --git a/src/Client/CheckoutService.php b/src/Client/CheckoutService.php index 1fa1311..a177422 100644 --- a/src/Client/CheckoutService.php +++ b/src/Client/CheckoutService.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace EsewaPayment\Client; +namespace Sujip\Esewa\Client; -use EsewaPayment\Config\EndpointResolver; -use EsewaPayment\Config\GatewayConfig; -use EsewaPayment\Domain\Checkout\CheckoutIntent; -use EsewaPayment\Domain\Checkout\CheckoutPayload; -use EsewaPayment\Domain\Checkout\CheckoutRequest; -use EsewaPayment\Service\SignatureService; +use Sujip\Esewa\Config\EndpointResolver; +use Sujip\Esewa\Config\GatewayConfig; +use Sujip\Esewa\Domain\Checkout\CheckoutIntent; +use Sujip\Esewa\Domain\Checkout\CheckoutPayload; +use Sujip\Esewa\Domain\Checkout\CheckoutRequest; +use Sujip\Esewa\Service\SignatureService; final class CheckoutService { @@ -25,17 +25,17 @@ public function createIntent(CheckoutRequest $request): CheckoutIntent $totalAmount = $request->totalAmount(); $signature = $this->signatures->generate( $totalAmount, - $request->transactionUuid, - $request->productCode, + $request->transactionUuid->value(), + $request->productCode->value(), $request->signedFieldNames, ); $payload = new CheckoutPayload( - amount: number_format((float) $request->amount, 2, '.', ''), - taxAmount: number_format((float) $request->taxAmount, 2, '.', ''), - serviceCharge: number_format((float) $request->serviceCharge, 2, '.', ''), - deliveryCharge: number_format((float) $request->deliveryCharge, 2, '.', ''), - totalAmount: $totalAmount, + amount: $request->amount, + taxAmount: $request->taxAmount, + serviceCharge: $request->serviceCharge, + deliveryCharge: $request->deliveryCharge, + totalAmount: \Sujip\Esewa\ValueObject\Amount::fromString($totalAmount), transactionUuid: $request->transactionUuid, productCode: $request->productCode, successUrl: $request->successUrl, diff --git a/src/Client/EsewaClient.php b/src/Client/EsewaClient.php index b47ece3..3b54cca 100644 --- a/src/Client/EsewaClient.php +++ b/src/Client/EsewaClient.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace EsewaPayment\Client; - -use EsewaPayment\Config\ClientOptions; -use EsewaPayment\Config\EndpointResolver; -use EsewaPayment\Config\GatewayConfig; -use EsewaPayment\Contracts\TransportInterface; -use EsewaPayment\Service\CallbackVerifier; -use EsewaPayment\Service\SignatureService; +namespace Sujip\Esewa\Client; + +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Config\EndpointResolver; +use Sujip\Esewa\Config\GatewayConfig; +use Sujip\Esewa\Contracts\TransportInterface; +use Sujip\Esewa\Service\CallbackVerifier; +use Sujip\Esewa\Service\SignatureService; final class EsewaClient { diff --git a/src/Client/TransactionService.php b/src/Client/TransactionService.php index b332365..e854702 100644 --- a/src/Client/TransactionService.php +++ b/src/Client/TransactionService.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace EsewaPayment\Client; +namespace Sujip\Esewa\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; +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Config\EndpointResolver; +use Sujip\Esewa\Config\GatewayConfig; +use Sujip\Esewa\Contracts\TransportInterface; +use Sujip\Esewa\Domain\Transaction\TransactionStatus; +use Sujip\Esewa\Domain\Transaction\TransactionStatusPayload; +use Sujip\Esewa\Domain\Transaction\TransactionStatusRequest; +use Sujip\Esewa\Exception\TransportException; final class TransactionService { @@ -25,11 +25,6 @@ public function __construct( public function fetchStatus(TransactionStatusRequest $query): TransactionStatus { - $this->options->logger->info('eSewa status check started.', [ - 'event' => 'esewa.status.started', - 'transaction_uuid' => $query->transactionUuid, - ]); - $attempt = 0; while (true) { @@ -37,45 +32,25 @@ 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, + 'product_code' => $query->productCode->value(), + 'total_amount' => $query->totalAmount->value(), + 'transaction_uuid' => $query->transactionUuid->value(), ] ); $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(), - ]); - + if (!$this->options->retryPolicy->shouldRetry($attempt, $exception)) { throw $exception; } + $delayUs = $this->options->retryPolicy->delayUs($attempt, $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); + if ($delayUs > 0) { + usleep($delayUs); } } } diff --git a/src/Config/ClientOptions.php b/src/Config/ClientOptions.php index d618054..905998d 100644 --- a/src/Config/ClientOptions.php +++ b/src/Config/ClientOptions.php @@ -2,21 +2,27 @@ declare(strict_types=1); -namespace EsewaPayment\Config; +namespace Sujip\Esewa\Config; -use EsewaPayment\Contracts\IdempotencyStoreInterface; -use EsewaPayment\Infrastructure\Idempotency\NullIdempotencyStore; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use Sujip\Esewa\Contracts\ClockInterface; +use Sujip\Esewa\Contracts\IdempotencyStoreInterface; +use Sujip\Esewa\Contracts\RetryPolicyInterface; +use Sujip\Esewa\Infrastructure\Idempotency\NullIdempotencyStore; +use Sujip\Esewa\Support\FixedDelayRetryPolicy; +use Sujip\Esewa\Support\SystemClock; -final class ClientOptions +final readonly class ClientOptions { + public readonly RetryPolicyInterface $retryPolicy; + public readonly ClockInterface $clock; + public function __construct( public readonly int $maxStatusRetries = 2, public readonly int $statusRetryDelayMs = 150, public readonly bool $preventCallbackReplay = true, public readonly IdempotencyStoreInterface $idempotencyStore = new NullIdempotencyStore(), - public readonly LoggerInterface $logger = new NullLogger(), + ?RetryPolicyInterface $retryPolicy = null, + ?ClockInterface $clock = null, ) { if ($maxStatusRetries < 0) { throw new \InvalidArgumentException('maxStatusRetries cannot be negative.'); @@ -25,5 +31,11 @@ public function __construct( if ($statusRetryDelayMs < 0) { throw new \InvalidArgumentException('statusRetryDelayMs cannot be negative.'); } + + $this->retryPolicy = $retryPolicy ?? new FixedDelayRetryPolicy( + maxRetries: $maxStatusRetries, + delayUs: $statusRetryDelayMs * 1000, + ); + $this->clock = $clock ?? new SystemClock(); } } diff --git a/src/Config/EndpointResolver.php b/src/Config/EndpointResolver.php index f80fdf0..2abc7de 100644 --- a/src/Config/EndpointResolver.php +++ b/src/Config/EndpointResolver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace EsewaPayment\Config; +namespace Sujip\Esewa\Config; final class EndpointResolver { diff --git a/src/Config/Environment.php b/src/Config/Environment.php index b373d45..b81e44a 100644 --- a/src/Config/Environment.php +++ b/src/Config/Environment.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace EsewaPayment\Config; +namespace Sujip\Esewa\Config; enum Environment: string { diff --git a/src/Config/GatewayConfig.php b/src/Config/GatewayConfig.php index 82edcfb..31db477 100644 --- a/src/Config/GatewayConfig.php +++ b/src/Config/GatewayConfig.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace EsewaPayment\Config; +namespace Sujip\Esewa\Config; -final class GatewayConfig +final readonly class GatewayConfig { public function __construct( public readonly string $merchantCode, diff --git a/src/Contracts/Arrayable.php b/src/Contracts/Arrayable.php new file mode 100644 index 0000000..cc643b1 --- /dev/null +++ b/src/Contracts/Arrayable.php @@ -0,0 +1,13 @@ + + */ + public function toArray(): array; +} diff --git a/src/Contracts/ClockInterface.php b/src/Contracts/ClockInterface.php new file mode 100644 index 0000000..b979cf6 --- /dev/null +++ b/src/Contracts/ClockInterface.php @@ -0,0 +1,10 @@ + $data + */ + public static function fromArray(array $data): static; +} diff --git a/src/Contracts/IdempotencyStoreInterface.php b/src/Contracts/IdempotencyStoreInterface.php index fd6842f..abfc85d 100644 --- a/src/Contracts/IdempotencyStoreInterface.php +++ b/src/Contracts/IdempotencyStoreInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace EsewaPayment\Contracts; +namespace Sujip\Esewa\Contracts; interface IdempotencyStoreInterface { diff --git a/src/Contracts/RetryPolicyInterface.php b/src/Contracts/RetryPolicyInterface.php new file mode 100644 index 0000000..e56fa1d --- /dev/null +++ b/src/Contracts/RetryPolicyInterface.php @@ -0,0 +1,14 @@ + $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, + 'amount' => $this->amount->value(), + 'tax_amount' => $this->taxAmount->value(), + 'product_service_charge' => $this->serviceCharge->value(), + 'product_delivery_charge' => $this->deliveryCharge->value(), + 'total_amount' => $this->totalAmount->value(), + 'transaction_uuid' => $this->transactionUuid->value(), + 'product_code' => $this->productCode->value(), 'success_url' => $this->successUrl, 'failure_url' => $this->failureUrl, 'signed_field_names' => $this->signedFieldNames, diff --git a/src/Domain/Checkout/CheckoutRequest.php b/src/Domain/Checkout/CheckoutRequest.php index 1558b1d..60682b6 100644 --- a/src/Domain/Checkout/CheckoutRequest.php +++ b/src/Domain/Checkout/CheckoutRequest.php @@ -2,24 +2,48 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Checkout; +namespace Sujip\Esewa\Domain\Checkout; -final class CheckoutRequest +use Sujip\Esewa\Contracts\Arrayable; +use Sujip\Esewa\Contracts\Hydratable; +use Sujip\Esewa\ValueObject\Amount; +use Sujip\Esewa\ValueObject\ProductCode; +use Sujip\Esewa\ValueObject\TransactionUuid; + +final readonly class CheckoutRequest implements Arrayable, Hydratable { + public readonly Amount $amount; + public readonly Amount $taxAmount; + public readonly Amount $serviceCharge; + public readonly Amount $deliveryCharge; + public readonly TransactionUuid $transactionUuid; + public readonly ProductCode $productCode; + public readonly string $successUrl; + public readonly string $failureUrl; + public readonly string $signedFieldNames; + public function __construct( - public readonly string $amount, - public readonly string $taxAmount, - public readonly string $serviceCharge, - public readonly string $deliveryCharge, - public readonly string $transactionUuid, - public readonly string $productCode, - public readonly string $successUrl, - public readonly string $failureUrl, - public readonly string $signedFieldNames = 'total_amount,transaction_uuid,product_code', + string|Amount $amount, + string|Amount $taxAmount, + string|Amount $serviceCharge, + string|Amount $deliveryCharge, + string|TransactionUuid $transactionUuid, + string|ProductCode $productCode, + string $successUrl, + string $failureUrl, + string $signedFieldNames = 'total_amount,transaction_uuid,product_code', ) { + $this->amount = self::normalizeAmount($amount); + $this->taxAmount = self::normalizeAmount($taxAmount); + $this->serviceCharge = self::normalizeAmount($serviceCharge); + $this->deliveryCharge = self::normalizeAmount($deliveryCharge); + $this->transactionUuid = self::normalizeTransactionUuid($transactionUuid); + $this->productCode = self::normalizeProductCode($productCode); + $this->successUrl = $successUrl; + $this->failureUrl = $failureUrl; + $this->signedFieldNames = $signedFieldNames; + foreach ([ - 'transactionUuid' => $transactionUuid, - 'productCode' => $productCode, 'successUrl' => $successUrl, 'failureUrl' => $failureUrl, ] as $field => $value) { @@ -29,13 +53,87 @@ public function __construct( } } + public static function make( + string|Amount $amount, + string|Amount $taxAmount, + string|Amount $serviceCharge, + string|Amount $deliveryCharge, + string|TransactionUuid $transactionUuid, + string|ProductCode $productCode, + string $successUrl, + string $failureUrl, + string $signedFieldNames = 'total_amount,transaction_uuid,product_code', + ): self { + return new self( + amount: self::normalizeAmount($amount), + taxAmount: self::normalizeAmount($taxAmount), + serviceCharge: self::normalizeAmount($serviceCharge), + deliveryCharge: self::normalizeAmount($deliveryCharge), + transactionUuid: self::normalizeTransactionUuid($transactionUuid), + productCode: self::normalizeProductCode($productCode), + successUrl: $successUrl, + failureUrl: $failureUrl, + signedFieldNames: $signedFieldNames, + ); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): static + { + return self::make( + amount: (string) ($data['amount'] ?? ''), + taxAmount: (string) ($data['tax_amount'] ?? $data['taxAmount'] ?? '0'), + serviceCharge: (string) ($data['service_charge'] ?? $data['serviceCharge'] ?? '0'), + deliveryCharge: (string) ($data['delivery_charge'] ?? $data['deliveryCharge'] ?? '0'), + transactionUuid: (string) ($data['transaction_uuid'] ?? $data['transactionUuid'] ?? ''), + productCode: (string) ($data['product_code'] ?? $data['productCode'] ?? ''), + successUrl: (string) ($data['success_url'] ?? $data['successUrl'] ?? ''), + failureUrl: (string) ($data['failure_url'] ?? $data['failureUrl'] ?? ''), + signedFieldNames: (string) ($data['signed_field_names'] ?? $data['signedFieldNames'] ?? 'total_amount,transaction_uuid,product_code'), + ); + } + public function totalAmount(): string { - $total = (float) $this->amount - + (float) $this->taxAmount - + (float) $this->serviceCharge - + (float) $this->deliveryCharge; + return $this->amount + ->add($this->taxAmount) + ->add($this->serviceCharge) + ->add($this->deliveryCharge) + ->value(); + } - return number_format($total, 2, '.', ''); + /** + * @return array + */ + public function toArray(): array + { + return [ + 'amount' => $this->amount->value(), + 'tax_amount' => $this->taxAmount->value(), + 'service_charge' => $this->serviceCharge->value(), + 'delivery_charge' => $this->deliveryCharge->value(), + 'transaction_uuid' => $this->transactionUuid->value(), + 'product_code' => $this->productCode->value(), + 'success_url' => $this->successUrl, + 'failure_url' => $this->failureUrl, + 'signed_field_names' => $this->signedFieldNames, + ]; + } + + private static function normalizeAmount(string|Amount $value): Amount + { + return $value instanceof Amount ? $value : Amount::fromString($value); + } + + private static function normalizeTransactionUuid(string|TransactionUuid $value): TransactionUuid + { + return $value instanceof TransactionUuid ? $value : TransactionUuid::fromString($value); + } + + private static function normalizeProductCode(string|ProductCode $value): ProductCode + { + return $value instanceof ProductCode ? $value : ProductCode::fromString($value); } } diff --git a/src/Domain/Transaction/PaymentStatus.php b/src/Domain/Transaction/PaymentStatus.php index 393b8c6..1f177d7 100644 --- a/src/Domain/Transaction/PaymentStatus.php +++ b/src/Domain/Transaction/PaymentStatus.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Transaction; +namespace Sujip\Esewa\Domain\Transaction; enum PaymentStatus: string { diff --git a/src/Domain/Transaction/TransactionStatus.php b/src/Domain/Transaction/TransactionStatus.php index 43e54fe..3f355f3 100644 --- a/src/Domain/Transaction/TransactionStatus.php +++ b/src/Domain/Transaction/TransactionStatus.php @@ -2,16 +2,19 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Transaction; +namespace Sujip\Esewa\Domain\Transaction; -final class TransactionStatus +use Sujip\Esewa\Contracts\Arrayable; +use Sujip\Esewa\ValueObject\ReferenceId; + +final readonly class TransactionStatus implements Arrayable { /** * @param array $raw */ public function __construct( public readonly PaymentStatus $status, - public readonly ?string $referenceId, + public readonly ?ReferenceId $referenceId, public readonly array $raw, ) { } @@ -20,4 +23,16 @@ public function isSuccessful(): bool { return $this->status === PaymentStatus::COMPLETE; } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'status' => $this->status->value, + 'reference_id' => $this->referenceId?->value(), + 'raw' => $this->raw, + ]; + } } diff --git a/src/Domain/Transaction/TransactionStatusPayload.php b/src/Domain/Transaction/TransactionStatusPayload.php index 112b99b..ced91ac 100644 --- a/src/Domain/Transaction/TransactionStatusPayload.php +++ b/src/Domain/Transaction/TransactionStatusPayload.php @@ -2,26 +2,32 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Transaction; +namespace Sujip\Esewa\Domain\Transaction; -final class TransactionStatusPayload +use Sujip\Esewa\Contracts\Arrayable; +use Sujip\Esewa\Contracts\Hydratable; +use Sujip\Esewa\ValueObject\ReferenceId; + +final readonly class TransactionStatusPayload implements Arrayable, Hydratable { /** * @param array $raw */ public function __construct( public readonly PaymentStatus $status, - public readonly ?string $referenceId, + public readonly ?ReferenceId $referenceId, public readonly array $raw, ) { } /** @param array $raw */ - public static function fromArray(array $raw): self + public static function fromArray(array $raw): static { - return new self( + return new static( status: PaymentStatus::fromValue(isset($raw['status']) ? (string) $raw['status'] : null), - referenceId: isset($raw['ref_id']) ? (string) $raw['ref_id'] : null, + referenceId: isset($raw['ref_id']) && (string) $raw['ref_id'] !== '' + ? ReferenceId::fromString((string) $raw['ref_id']) + : null, raw: $raw, ); } @@ -30,4 +36,16 @@ public function toResult(): TransactionStatus { return new TransactionStatus($this->status, $this->referenceId, $this->raw); } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'status' => $this->status->value, + 'ref_id' => $this->referenceId?->value(), + 'raw' => $this->raw, + ]; + } } diff --git a/src/Domain/Transaction/TransactionStatusRequest.php b/src/Domain/Transaction/TransactionStatusRequest.php index c8f5fbe..e77b0c6 100644 --- a/src/Domain/Transaction/TransactionStatusRequest.php +++ b/src/Domain/Transaction/TransactionStatusRequest.php @@ -2,17 +2,63 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Transaction; +namespace Sujip\Esewa\Domain\Transaction; -final class TransactionStatusRequest +use Sujip\Esewa\Contracts\Arrayable; +use Sujip\Esewa\Contracts\Hydratable; +use Sujip\Esewa\ValueObject\Amount; +use Sujip\Esewa\ValueObject\ProductCode; +use Sujip\Esewa\ValueObject\TransactionUuid; + +final readonly class TransactionStatusRequest implements Arrayable, Hydratable { + public readonly TransactionUuid $transactionUuid; + public readonly Amount $totalAmount; + public readonly ProductCode $productCode; + public function __construct( - public readonly string $transactionUuid, - public readonly string $totalAmount, - public readonly string $productCode, + string|TransactionUuid $transactionUuid, + string|Amount $totalAmount, + string|ProductCode $productCode, ) { - if ($transactionUuid === '' || $totalAmount === '' || $productCode === '') { - throw new \InvalidArgumentException('transactionUuid, totalAmount and productCode are required.'); - } + $this->transactionUuid = $transactionUuid instanceof TransactionUuid ? $transactionUuid : TransactionUuid::fromString($transactionUuid); + $this->totalAmount = $totalAmount instanceof Amount ? $totalAmount : Amount::fromString($totalAmount); + $this->productCode = $productCode instanceof ProductCode ? $productCode : ProductCode::fromString($productCode); + } + + public static function make( + string|TransactionUuid $transactionUuid, + string|Amount $totalAmount, + string|ProductCode $productCode, + ): self { + return new self( + transactionUuid: $transactionUuid, + totalAmount: $totalAmount, + productCode: $productCode, + ); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): static + { + return self::make( + transactionUuid: (string) ($data['transaction_uuid'] ?? $data['transactionUuid'] ?? ''), + totalAmount: (string) ($data['total_amount'] ?? $data['totalAmount'] ?? ''), + productCode: (string) ($data['product_code'] ?? $data['productCode'] ?? ''), + ); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'transaction_uuid' => $this->transactionUuid->value(), + 'total_amount' => $this->totalAmount->value(), + 'product_code' => $this->productCode->value(), + ]; } } diff --git a/src/Domain/Verification/CallbackData.php b/src/Domain/Verification/CallbackData.php index 9fa6036..1537031 100644 --- a/src/Domain/Verification/CallbackData.php +++ b/src/Domain/Verification/CallbackData.php @@ -2,29 +2,35 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Verification; +namespace Sujip\Esewa\Domain\Verification; -use EsewaPayment\Domain\Transaction\PaymentStatus; -use EsewaPayment\Exception\InvalidPayloadException; +use Sujip\Esewa\Contracts\Arrayable; +use Sujip\Esewa\Contracts\Hydratable; +use Sujip\Esewa\Domain\Transaction\PaymentStatus; +use Sujip\Esewa\Exception\InvalidPayloadException; +use Sujip\Esewa\ValueObject\Amount; +use Sujip\Esewa\ValueObject\ProductCode; +use Sujip\Esewa\ValueObject\ReferenceId; +use Sujip\Esewa\ValueObject\TransactionUuid; -final class CallbackData +final readonly class CallbackData implements Arrayable, Hydratable { /** * @param array $raw */ public function __construct( - public readonly string $totalAmount, - public readonly string $transactionUuid, - public readonly string $productCode, + public readonly Amount $totalAmount, + public readonly TransactionUuid $transactionUuid, + public readonly ProductCode $productCode, public readonly string $signedFieldNames, public readonly PaymentStatus $status, - public readonly ?string $transactionCode, + public readonly ?ReferenceId $transactionCode, public readonly array $raw, ) { } /** @param array $data */ - public static function fromArray(array $data): self + public static function fromArray(array $data): static { $totalAmount = (string) ($data['total_amount'] ?? ''); $transactionUuid = (string) ($data['transaction_uuid'] ?? ''); @@ -34,14 +40,31 @@ public static function fromArray(array $data): self throw new InvalidPayloadException('Callback data is missing required fields.'); } - return new self( - totalAmount: $totalAmount, - transactionUuid: $transactionUuid, - productCode: $productCode, + return new static( + totalAmount: Amount::fromString($totalAmount), + transactionUuid: TransactionUuid::fromString($transactionUuid), + productCode: ProductCode::fromString($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, + transactionCode: isset($data['transaction_code']) && (string) $data['transaction_code'] !== '' + ? ReferenceId::fromString((string) $data['transaction_code']) + : null, raw: $data, ); } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'total_amount' => $this->totalAmount->value(), + 'transaction_uuid' => $this->transactionUuid->value(), + 'product_code' => $this->productCode->value(), + 'signed_field_names' => $this->signedFieldNames, + 'status' => $this->status->value, + 'transaction_code' => $this->transactionCode?->value(), + ]; + } } diff --git a/src/Domain/Verification/CallbackPayload.php b/src/Domain/Verification/CallbackPayload.php index 240f731..7c1d6d9 100644 --- a/src/Domain/Verification/CallbackPayload.php +++ b/src/Domain/Verification/CallbackPayload.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Verification; +namespace Sujip\Esewa\Domain\Verification; -use EsewaPayment\Exception\InvalidPayloadException; +use Sujip\Esewa\Exception\InvalidPayloadException; -final class CallbackPayload +final readonly class CallbackPayload { public function __construct( public readonly string $data, diff --git a/src/Domain/Verification/CallbackVerification.php b/src/Domain/Verification/CallbackVerification.php index be7c7f0..aa293f9 100644 --- a/src/Domain/Verification/CallbackVerification.php +++ b/src/Domain/Verification/CallbackVerification.php @@ -2,19 +2,22 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Verification; +namespace Sujip\Esewa\Domain\Verification; -use EsewaPayment\Domain\Transaction\PaymentStatus; +use Sujip\Esewa\Contracts\Arrayable; +use Sujip\Esewa\Domain\Transaction\PaymentStatus; +use Sujip\Esewa\ValueObject\ReferenceId; -final class CallbackVerification +final readonly class CallbackVerification implements Arrayable { /** * @param array $raw */ public function __construct( + public readonly VerificationState $state, public readonly bool $valid, public readonly PaymentStatus $status, - public readonly ?string $referenceId, + public readonly ?ReferenceId $referenceId, public readonly string $message, public readonly array $raw, ) { @@ -24,4 +27,24 @@ public function isSuccessful(): bool { return $this->valid && $this->status === PaymentStatus::COMPLETE; } + + public function isReplayed(): bool + { + return $this->state === VerificationState::REPLAYED; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'state' => $this->state->value, + 'valid' => $this->valid, + 'status' => $this->status->value, + 'reference_id' => $this->referenceId?->value(), + 'message' => $this->message, + 'raw' => $this->raw, + ]; + } } diff --git a/src/Domain/Verification/VerificationExpectation.php b/src/Domain/Verification/VerificationExpectation.php index 682ed23..e775e22 100644 --- a/src/Domain/Verification/VerificationExpectation.php +++ b/src/Domain/Verification/VerificationExpectation.php @@ -2,15 +2,78 @@ declare(strict_types=1); -namespace EsewaPayment\Domain\Verification; +namespace Sujip\Esewa\Domain\Verification; -final class VerificationExpectation +use Sujip\Esewa\Contracts\Arrayable; +use Sujip\Esewa\Contracts\Hydratable; +use Sujip\Esewa\ValueObject\Amount; +use Sujip\Esewa\ValueObject\ProductCode; +use Sujip\Esewa\ValueObject\ReferenceId; +use Sujip\Esewa\ValueObject\TransactionUuid; + +final readonly class VerificationExpectation implements Arrayable, Hydratable { + public readonly Amount $totalAmount; + public readonly TransactionUuid $transactionUuid; + public readonly ProductCode $productCode; + public readonly ?ReferenceId $referenceId; + public function __construct( - public readonly string $totalAmount, - public readonly string $transactionUuid, - public readonly string $productCode, - public readonly ?string $referenceId = null, + string|Amount $totalAmount, + string|TransactionUuid $transactionUuid, + string|ProductCode $productCode, + string|ReferenceId|null $referenceId = null, ) { + $this->totalAmount = $totalAmount instanceof Amount ? $totalAmount : Amount::fromString($totalAmount); + $this->transactionUuid = $transactionUuid instanceof TransactionUuid ? $transactionUuid : TransactionUuid::fromString($transactionUuid); + $this->productCode = $productCode instanceof ProductCode ? $productCode : ProductCode::fromString($productCode); + $this->referenceId = $referenceId instanceof ReferenceId || $referenceId === null ? $referenceId : ReferenceId::fromString($referenceId); + } + + public static function make( + string|Amount $totalAmount, + string|TransactionUuid $transactionUuid, + string|ProductCode $productCode, + string|ReferenceId|null $referenceId = null, + ): self { + return new self( + totalAmount: $totalAmount, + transactionUuid: $transactionUuid, + productCode: $productCode, + referenceId: $referenceId, + ); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): static + { + return self::make( + totalAmount: (string) ($data['total_amount'] ?? $data['totalAmount'] ?? ''), + transactionUuid: (string) ($data['transaction_uuid'] ?? $data['transactionUuid'] ?? ''), + productCode: (string) ($data['product_code'] ?? $data['productCode'] ?? ''), + referenceId: isset($data['reference_id']) || isset($data['referenceId']) + ? (string) ($data['reference_id'] ?? $data['referenceId']) + : null, + ); + } + + /** + * @return array + */ + public function toArray(): array + { + $payload = [ + 'total_amount' => $this->totalAmount->value(), + 'transaction_uuid' => $this->transactionUuid->value(), + 'product_code' => $this->productCode->value(), + ]; + + if ($this->referenceId !== null) { + $payload['reference_id'] = $this->referenceId->value(); + } + + return $payload; } } diff --git a/src/Domain/Verification/VerificationState.php b/src/Domain/Verification/VerificationState.php new file mode 100644 index 0000000..28f0af3 --- /dev/null +++ b/src/Domain/Verification/VerificationState.php @@ -0,0 +1,12 @@ +ttlSeconds < 1) { + throw new \InvalidArgumentException('ttlSeconds must be at least 1.'); + } + + if (!is_dir($this->directory) && !@mkdir($this->directory, 0777, true) && !is_dir($this->directory)) { + throw new \RuntimeException(sprintf('Unable to create idempotency directory: %s', $this->directory)); + } + } + + public function has(string $key): bool + { + $path = $this->path($key); + + if (!is_file($path)) { + return false; + } + + $expiresAt = (int) trim((string) file_get_contents($path)); + if ($expiresAt <= $this->clock->now()->getTimestamp()) { + @unlink($path); + + return false; + } + + return true; + } + + public function put(string $key): void + { + $expiresAt = $this->clock->now()->getTimestamp() + $this->ttlSeconds; + file_put_contents($this->path($key), (string) $expiresAt, LOCK_EX); + } + + private function path(string $key): string + { + return rtrim($this->directory, '/').'/'.hash('sha256', $key).'.lock'; + } +} diff --git a/src/Infrastructure/Idempotency/InMemoryIdempotencyStore.php b/src/Infrastructure/Idempotency/InMemoryIdempotencyStore.php index 8eff353..ab787cf 100644 --- a/src/Infrastructure/Idempotency/InMemoryIdempotencyStore.php +++ b/src/Infrastructure/Idempotency/InMemoryIdempotencyStore.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace EsewaPayment\Infrastructure\Idempotency; +namespace Sujip\Esewa\Infrastructure\Idempotency; -use EsewaPayment\Contracts\IdempotencyStoreInterface; +use Sujip\Esewa\Contracts\IdempotencyStoreInterface; final class InMemoryIdempotencyStore implements IdempotencyStoreInterface { diff --git a/src/Infrastructure/Idempotency/NullIdempotencyStore.php b/src/Infrastructure/Idempotency/NullIdempotencyStore.php index a8782b3..a35c5a2 100644 --- a/src/Infrastructure/Idempotency/NullIdempotencyStore.php +++ b/src/Infrastructure/Idempotency/NullIdempotencyStore.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace EsewaPayment\Infrastructure\Idempotency; +namespace Sujip\Esewa\Infrastructure\Idempotency; -use EsewaPayment\Contracts\IdempotencyStoreInterface; +use Sujip\Esewa\Contracts\IdempotencyStoreInterface; final class NullIdempotencyStore implements IdempotencyStoreInterface { diff --git a/src/Infrastructure/Idempotency/PdoIdempotencyStore.php b/src/Infrastructure/Idempotency/PdoIdempotencyStore.php new file mode 100644 index 0000000..0bbf0ca --- /dev/null +++ b/src/Infrastructure/Idempotency/PdoIdempotencyStore.php @@ -0,0 +1,65 @@ +ttlSeconds < 1) { + throw new \InvalidArgumentException('ttlSeconds must be at least 1.'); + } + + $this->initialize(); + } + + public function has(string $key): bool + { + $this->purgeExpired(); + + $statement = $this->pdo->prepare(sprintf('SELECT 1 FROM %s WHERE idempotency_key = :key LIMIT 1', $this->table)); + $statement->execute(['key' => $key]); + + return (bool) $statement->fetchColumn(); + } + + public function put(string $key): void + { + $statement = $this->pdo->prepare(sprintf( + 'INSERT OR REPLACE INTO %s (idempotency_key, expires_at) VALUES (:key, :expires_at)', + $this->table + )); + + $statement->execute([ + 'key' => $key, + 'expires_at' => $this->clock->now()->getTimestamp() + $this->ttlSeconds, + ]); + } + + private function initialize(): void + { + $this->pdo->exec(sprintf( + 'CREATE TABLE IF NOT EXISTS %s (idempotency_key VARCHAR(255) PRIMARY KEY, expires_at INTEGER NOT NULL)', + $this->table + )); + } + + private function purgeExpired(): void + { + $statement = $this->pdo->prepare(sprintf('DELETE FROM %s WHERE expires_at <= :expires_at', $this->table)); + $statement->execute([ + 'expires_at' => $this->clock->now()->getTimestamp(), + ]); + } +} diff --git a/src/Infrastructure/Transport/CurlTransport.php b/src/Infrastructure/Transport/CurlTransport.php new file mode 100644 index 0000000..fa63e7b --- /dev/null +++ b/src/Infrastructure/Transport/CurlTransport.php @@ -0,0 +1,75 @@ +timeoutSeconds < 1) { + throw new \InvalidArgumentException('timeoutSeconds must be at least 1.'); + } + } + + public function get(string $url, array $query = [], array $headers = []): array + { + if (!function_exists('curl_init')) { + throw new TransportException('ext-curl is required for CurlTransport.'); + } + + $fullUrl = $url; + if ($query !== []) { + $fullUrl .= (str_contains($url, '?') ? '&' : '?').http_build_query($query); + } + + $curl = curl_init($fullUrl); + if ($curl === false) { + throw new TransportException('Failed to initialize curl transport.'); + } + + $normalizedHeaders = ['Accept: application/json']; + foreach ($headers as $name => $value) { + $normalizedHeaders[] = $name.': '.$value; + } + + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_HTTPHEADER => $normalizedHeaders, + ]); + + $body = curl_exec($curl); + if ($body === false) { + $message = curl_error($curl); + curl_close($curl); + + throw new TransportException('HTTP request failed: '.$message); + } + + $statusCode = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + curl_close($curl); + + if ($statusCode < 200 || $statusCode >= 300) { + throw new TransportException('Unexpected HTTP status: '.$statusCode); + } + + if (!is_string($body)) { + throw new ApiErrorException('Unexpected non-string response from eSewa status API.'); + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new ApiErrorException('Invalid JSON response from eSewa status API.'); + } + + return $decoded; + } +} diff --git a/src/Infrastructure/Transport/Psr18Transport.php b/src/Infrastructure/Transport/Psr18Transport.php index ac3b01c..57dca61 100644 --- a/src/Infrastructure/Transport/Psr18Transport.php +++ b/src/Infrastructure/Transport/Psr18Transport.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace EsewaPayment\Infrastructure\Transport; +namespace Sujip\Esewa\Infrastructure\Transport; -use EsewaPayment\Contracts\TransportInterface; -use EsewaPayment\Exception\ApiErrorException; -use EsewaPayment\Exception\TransportException; +use Sujip\Esewa\Contracts\TransportInterface; +use Sujip\Esewa\Exception\ApiErrorException; +use Sujip\Esewa\Exception\TransportException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; diff --git a/src/Omnipay/Message/AbstractRequest.php b/src/Omnipay/Message/AbstractRequest.php new file mode 100644 index 0000000..6fdf6f7 --- /dev/null +++ b/src/Omnipay/Message/AbstractRequest.php @@ -0,0 +1,171 @@ +getParameter('merchantCode'); + } + + public function setMerchantCode(string $value): self + { + return $this->setParameter('merchantCode', $value); + } + + public function getSecretKey(): string + { + return (string) $this->getParameter('secretKey'); + } + + public function setSecretKey(string $value): self + { + return $this->setParameter('secretKey', $value); + } + + public function getTaxAmount(): string + { + return (string) $this->getParameter('taxAmount'); + } + + public function setTaxAmount(string $value): self + { + return $this->setParameter('taxAmount', $value); + } + + public function getServiceCharge(): string + { + return (string) $this->getParameter('serviceCharge'); + } + + public function setServiceCharge(string $value): self + { + return $this->setParameter('serviceCharge', $value); + } + + public function getDeliveryCharge(): string + { + return (string) $this->getParameter('deliveryCharge'); + } + + public function setDeliveryCharge(string $value): self + { + return $this->setParameter('deliveryCharge', $value); + } + + public function getTotalAmount(): string + { + return (string) $this->getParameter('totalAmount'); + } + + public function setTotalAmount(string $value): self + { + return $this->setParameter('totalAmount', $value); + } + + public function getProductCode(): string + { + return (string) $this->getParameter('productCode'); + } + + public function setProductCode(string $value): self + { + return $this->setParameter('productCode', $value); + } + + public function getFailureUrl(): string + { + return (string) $this->getParameter('failureUrl'); + } + + public function getFailedUrl(): string + { + return $this->getFailureUrl(); + } + + public function setFailureUrl(string $value): self + { + return $this->setParameter('failureUrl', $value); + } + + public function setFailedUrl(string $value): self + { + return $this->setFailureUrl($value); + } + + public function getReferenceNumber(): string + { + return (string) $this->getParameter('referenceNumber'); + } + + public function setReferenceNumber(string $value): self + { + return $this->setParameter('referenceNumber', $value); + } + + public function getTransactionUuid(): string + { + return (string) $this->getParameter('transactionUuid'); + } + + public function setTransactionUuid(string $value): self + { + return $this->setParameter('transactionUuid', $value); + } + + public function setTransport(TransportInterface $transport): self + { + return $this->setParameter('transport', $transport); + } + + public function getTransport(): ?TransportInterface + { + $transport = $this->getParameter('transport'); + + return $transport instanceof TransportInterface ? $transport : null; + } + + public function getTimeoutSeconds(): int + { + $timeout = $this->getParameter('timeoutSeconds'); + + if ($timeout === null) { + return 30; + } + + return max(1, (int) $timeout); + } + + public function setTimeoutSeconds(int $value): self + { + return $this->setParameter('timeoutSeconds', max(1, $value)); + } + + protected function gatewayConfig(): GatewayConfig + { + $productCode = $this->getProductCode(); + if ($productCode === '') { + $productCode = $this->getMerchantCode(); + $this->setProductCode($productCode); + } + + return GatewayConfig::make( + merchantCode: $this->getMerchantCode(), + secretKey: $this->getSecretKey(), + environment: $this->getTestMode() ? Environment::UAT : Environment::PRODUCTION, + ); + } + + protected function transport(): TransportInterface + { + return $this->getTransport() ?? new CurlTransport($this->getTimeoutSeconds()); + } +} diff --git a/src/Omnipay/Message/CompletePurchaseRequest.php b/src/Omnipay/Message/CompletePurchaseRequest.php new file mode 100644 index 0000000..1b0b45f --- /dev/null +++ b/src/Omnipay/Message/CompletePurchaseRequest.php @@ -0,0 +1,80 @@ + + */ + public function getData(): array + { + $httpRequest = $this->httpRequest; + if (isset($httpRequest->query) && is_object($httpRequest->query) && method_exists($httpRequest->query, 'all')) { + /** @var array $query */ + $query = $httpRequest->query->all(); + if ($query !== []) { + return $query; + } + } + + if (isset($httpRequest->request) && is_object($httpRequest->request) && method_exists($httpRequest->request, 'all')) { + /** @var array $request */ + $request = $httpRequest->request->all(); + if ($request !== []) { + return $request; + } + } + + return []; + } + + /** + * @param array $data + */ + public function sendData($data): CompletePurchaseResponse + { + $payload = CallbackPayload::fromArray($data); + + $service = new CallbackService(new CallbackVerifier( + new SignatureService($this->getSecretKey()), + new ClientOptions(preventCallbackReplay: false) + )); + + $referenceId = $this->getReferenceNumber(); + $context = null; + + $transactionUuid = $this->getTransactionUuid(); + $productCode = $this->getProductCode() !== '' ? $this->getProductCode() : $this->getMerchantCode(); + $amount = $this->getAmount(); + $totalAmount = $this->getTotalAmount() !== '' ? $this->getTotalAmount() : ($amount ?? ''); + + if ($transactionUuid !== '' && $productCode !== '' && $totalAmount !== '') { + $context = new VerificationExpectation( + totalAmount: $totalAmount, + transactionUuid: $transactionUuid, + productCode: $productCode, + referenceId: $referenceId !== '' ? $referenceId : null, + ); + } + + $result = $service->verifyCallback($payload, $context); + + return $this->response = new CompletePurchaseResponse($this, [ + 'status' => $result->status->value, + 'valid' => $result->valid, + 'reference_id' => $result->referenceId?->value(), + 'message' => $result->message, + 'raw' => $result->raw, + ]); + } +} diff --git a/src/Omnipay/Message/CompletePurchaseResponse.php b/src/Omnipay/Message/CompletePurchaseResponse.php new file mode 100644 index 0000000..c3e1655 --- /dev/null +++ b/src/Omnipay/Message/CompletePurchaseResponse.php @@ -0,0 +1,37 @@ + $data + */ + public function __construct(RequestInterface $request, array $data) + { + $this->request = $request; + $this->data = $data; + } + + public function isSuccessful(): bool + { + return (bool) ($this->data['valid'] ?? false) && (string) ($this->data['status'] ?? '') === 'COMPLETE'; + } + + public function getMessage(): string + { + return (string) ($this->data['message'] ?? ''); + } + + public function getTransactionReference(): ?string + { + $referenceId = $this->data['reference_id'] ?? null; + + return is_string($referenceId) && $referenceId !== '' ? $referenceId : null; + } +} diff --git a/src/Omnipay/Message/PurchaseRequest.php b/src/Omnipay/Message/PurchaseRequest.php new file mode 100644 index 0000000..bfb16d7 --- /dev/null +++ b/src/Omnipay/Message/PurchaseRequest.php @@ -0,0 +1,63 @@ + + */ + public function getData(): array + { + if ($this->getProductCode() === '') { + $this->setProductCode($this->getMerchantCode()); + } + + $this->validate('merchantCode', 'secretKey', 'amount', 'productCode', 'returnUrl', 'failureUrl'); + + $transactionUuid = $this->getTransactionUuid(); + if ($transactionUuid === '') { + $transactionUuid = $this->getTransactionId(); + } + + if ($transactionUuid === '') { + throw new \InvalidArgumentException('transactionUuid or transactionId is required.'); + } + + return [ + 'transaction_uuid' => $transactionUuid, + ]; + } + + /** + * @param array $data + */ + public function sendData($data): PurchaseResponse + { + $service = new CheckoutService( + $this->gatewayConfig(), + new EndpointResolver(), + new SignatureService($this->getSecretKey()), + ); + + $intent = $service->createIntent(new CoreCheckoutRequest( + amount: (string) $this->getAmount(), + taxAmount: $this->getTaxAmount() !== '' ? $this->getTaxAmount() : '0', + serviceCharge: $this->getServiceCharge() !== '' ? $this->getServiceCharge() : '0', + deliveryCharge: $this->getDeliveryCharge() !== '' ? $this->getDeliveryCharge() : '0', + transactionUuid: (string) $data['transaction_uuid'], + productCode: $this->getProductCode(), + successUrl: (string) $this->getReturnUrl(), + failureUrl: $this->getFailureUrl(), + )); + + return $this->response = new PurchaseResponse($this, $intent->fields(), $intent->actionUrl); + } +} diff --git a/src/Omnipay/Message/PurchaseResponse.php b/src/Omnipay/Message/PurchaseResponse.php new file mode 100644 index 0000000..ead64a1 --- /dev/null +++ b/src/Omnipay/Message/PurchaseResponse.php @@ -0,0 +1,49 @@ + $data + */ + public function __construct(RequestInterface $request, array $data, private readonly string $redirectUrl) + { + $this->request = $request; + $this->data = $data; + } + + public function isSuccessful(): bool + { + return false; + } + + public function isRedirect(): bool + { + return true; + } + + public function getRedirectUrl(): string + { + return $this->redirectUrl; + } + + public function getRedirectMethod(): string + { + return 'POST'; + } + + /** + * @return array + */ + public function getRedirectData(): array + { + return $this->getData(); + } +} diff --git a/src/Omnipay/Message/VerifyPaymentRequest.php b/src/Omnipay/Message/VerifyPaymentRequest.php new file mode 100644 index 0000000..6879cb1 --- /dev/null +++ b/src/Omnipay/Message/VerifyPaymentRequest.php @@ -0,0 +1,64 @@ + + */ + public function getData(): array + { + if ($this->getProductCode() === '') { + $this->setProductCode($this->getMerchantCode()); + } + + $this->validate('merchantCode', 'amount', 'productCode'); + + $transactionUuid = $this->getTransactionUuid(); + if ($transactionUuid === '') { + $transactionUuid = $this->getTransactionId(); + } + + if ($transactionUuid === '') { + throw new \InvalidArgumentException('transactionUuid or transactionId is required.'); + } + + return [ + 'transaction_uuid' => $transactionUuid, + 'total_amount' => $this->getTotalAmount() !== '' ? $this->getTotalAmount() : (string) $this->getAmount(), + 'product_code' => $this->getProductCode(), + ]; + } + + /** + * @param array $data + */ + public function sendData($data): VerifyPaymentResponse + { + $service = new TransactionService( + $this->gatewayConfig(), + new EndpointResolver(), + $this->transport(), + new ClientOptions(), + ); + + $result = $service->fetchStatus(new CoreTransactionStatusRequest( + transactionUuid: (string) $data['transaction_uuid'], + totalAmount: (string) $data['total_amount'], + productCode: (string) $data['product_code'], + )); + + return $this->response = new VerifyPaymentResponse($this, [ + 'status' => $result->status->value, + 'ref_id' => $result->referenceId?->value(), + ]); + } +} diff --git a/src/Omnipay/Message/VerifyPaymentResponse.php b/src/Omnipay/Message/VerifyPaymentResponse.php new file mode 100644 index 0000000..23e0e35 --- /dev/null +++ b/src/Omnipay/Message/VerifyPaymentResponse.php @@ -0,0 +1,37 @@ + $data + */ + public function __construct(RequestInterface $request, array $data) + { + $this->request = $request; + $this->data = $data; + } + + public function getResponseText(): string + { + return (string) ($this->data['status'] ?? ''); + } + + public function getReferenceId(): ?string + { + $referenceId = $this->data['ref_id'] ?? null; + + return is_string($referenceId) && $referenceId !== '' ? $referenceId : null; + } + + public function isSuccessful(): bool + { + return $this->getResponseText() === 'COMPLETE'; + } +} diff --git a/src/Omnipay/SecureGateway.php b/src/Omnipay/SecureGateway.php new file mode 100644 index 0000000..19d07f4 --- /dev/null +++ b/src/Omnipay/SecureGateway.php @@ -0,0 +1,168 @@ + '', + 'secretKey' => '', + 'productCode' => '', + 'taxAmount' => '0', + 'serviceCharge' => '0', + 'deliveryCharge' => '0', + 'testMode' => false, + 'transactionUuid' => '', + 'failureUrl' => '', + 'timeoutSeconds' => 30, + 'transport' => null, + ]; + } + + public function getMerchantCode(): string + { + return (string) $this->getParameter('merchantCode'); + } + + public function setMerchantCode(string $value): self + { + return $this->setParameter('merchantCode', $value); + } + + public function getSecretKey(): string + { + return (string) $this->getParameter('secretKey'); + } + + public function setSecretKey(string $value): self + { + return $this->setParameter('secretKey', $value); + } + + public function getTaxAmount(): string + { + return (string) $this->getParameter('taxAmount'); + } + + public function setTaxAmount(string $value): self + { + return $this->setParameter('taxAmount', $value); + } + + public function getServiceCharge(): string + { + return (string) $this->getParameter('serviceCharge'); + } + + public function setServiceCharge(string $value): self + { + return $this->setParameter('serviceCharge', $value); + } + + public function getDeliveryCharge(): string + { + return (string) $this->getParameter('deliveryCharge'); + } + + public function setDeliveryCharge(string $value): self + { + return $this->setParameter('deliveryCharge', $value); + } + + public function getTransactionUuid(): string + { + return (string) $this->getParameter('transactionUuid'); + } + + public function setTransactionUuid(string $value): self + { + return $this->setParameter('transactionUuid', $value); + } + + public function getProductCode(): string + { + return (string) $this->getParameter('productCode'); + } + + public function setProductCode(string $value): self + { + return $this->setParameter('productCode', $value); + } + + public function getFailureUrl(): string + { + return (string) $this->getParameter('failureUrl'); + } + + public function getFailedUrl(): string + { + return $this->getFailureUrl(); + } + + public function setFailureUrl(string $value): self + { + return $this->setParameter('failureUrl', $value); + } + + public function setFailedUrl(string $value): self + { + return $this->setFailureUrl($value); + } + + public function getReferenceNumber(): string + { + return (string) $this->getParameter('referenceNumber'); + } + + public function setReferenceNumber(string $value): self + { + return $this->setParameter('referenceNumber', $value); + } + + /** + * @param array $parameters + */ + public function completePurchase(array $parameters = []): \Omnipay\Esewa\Message\CompletePurchaseRequest + { + $request = $this->createRequest(\Omnipay\Esewa\Message\CompletePurchaseRequest::class, $parameters); + + \assert($request instanceof \Omnipay\Esewa\Message\CompletePurchaseRequest); + + return $request; + } + + /** + * @param array $parameters + */ + public function purchase(array $parameters = []): \Omnipay\Esewa\Message\PurchaseRequest + { + $request = $this->createRequest(\Omnipay\Esewa\Message\PurchaseRequest::class, $parameters); + + \assert($request instanceof \Omnipay\Esewa\Message\PurchaseRequest); + + return $request; + } + + /** + * @param array $parameters + */ + public function verifyPayment(array $parameters = []): \Omnipay\Esewa\Message\VerifyPaymentRequest + { + $request = $this->createRequest(\Omnipay\Esewa\Message\VerifyPaymentRequest::class, $parameters); + + \assert($request instanceof \Omnipay\Esewa\Message\VerifyPaymentRequest); + + return $request; + } +} diff --git a/src/Service/CallbackVerifier.php b/src/Service/CallbackVerifier.php index fc00f60..8245e4e 100644 --- a/src/Service/CallbackVerifier.php +++ b/src/Service/CallbackVerifier.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace EsewaPayment\Service; +namespace Sujip\Esewa\Service; -use EsewaPayment\Config\ClientOptions; -use EsewaPayment\Domain\Verification\CallbackPayload; -use EsewaPayment\Domain\Verification\CallbackVerification; -use EsewaPayment\Domain\Verification\VerificationExpectation; -use EsewaPayment\Exception\FraudValidationException; +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Domain\Verification\CallbackPayload; +use Sujip\Esewa\Domain\Verification\CallbackVerification; +use Sujip\Esewa\Domain\Verification\VerificationExpectation; +use Sujip\Esewa\Domain\Verification\VerificationState; +use Sujip\Esewa\Exception\FraudValidationException; final class CallbackVerifier { @@ -25,19 +26,15 @@ public function verify(CallbackPayload $payload, ?VerificationExpectation $conte $validSignature = $this->signatures->verify( $payload->signature, - $data->totalAmount, - $data->transactionUuid, - $data->productCode, + $data->totalAmount->value(), + $data->transactionUuid->value(), + $data->productCode->value(), $data->signedFieldNames ); if (!$validSignature) { - $this->options->logger->warning('eSewa callback rejected due to invalid signature.', [ - 'event' => 'esewa.callback.invalid_signature', - 'transaction_uuid' => $data->transactionUuid, - ]); - return new CallbackVerification( + VerificationState::INVALID_SIGNATURE, false, $data->status, $data->transactionCode, @@ -47,20 +44,21 @@ public function verify(CallbackPayload $payload, ?VerificationExpectation $conte } if ($context !== null) { - $this->assertConsistent($context, $data->totalAmount, $data->transactionUuid, $data->productCode, $data->transactionCode); + $this->assertConsistent( + $context, + $data->totalAmount->value(), + $data->transactionUuid->value(), + $data->productCode->value(), + $data->transactionCode?->value() + ); } 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( + VerificationState::REPLAYED, false, $data->status, $data->transactionCode, @@ -72,14 +70,14 @@ public function verify(CallbackPayload $payload, ?VerificationExpectation $conte $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); + return new CallbackVerification( + VerificationState::VERIFIED, + true, + $data->status, + $data->transactionCode, + 'Callback verified.', + $data->raw + ); } private function assertConsistent( @@ -89,33 +87,27 @@ private function assertConsistent( string $productCode, ?string $referenceId ): void { - if ($context->totalAmount !== $totalAmount) { + if ($context->totalAmount->value() !== $totalAmount) { throw new FraudValidationException('total_amount mismatch during callback verification.'); } - if ($context->transactionUuid !== $transactionUuid) { + if ($context->transactionUuid->value() !== $transactionUuid) { throw new FraudValidationException('transaction_uuid mismatch during callback verification.'); } - if ($context->productCode !== $productCode) { + if ($context->productCode->value() !== $productCode) { throw new FraudValidationException('product_code mismatch during callback verification.'); } - if ($context->referenceId !== null && $context->referenceId !== $referenceId) { - $this->options->logger->warning('eSewa callback fraud validation failed.', [ - 'event' => 'esewa.callback.fraud_reference_mismatch', - 'expected_reference_id' => $context->referenceId, - 'actual_reference_id' => $referenceId, - ]); - + if ($context->referenceId !== null && $context->referenceId->value() !== $referenceId) { throw new FraudValidationException('reference_id mismatch during callback verification.'); } } - private function resolveIdempotencyKey(CallbackPayload $payload, ?string $referenceId): string + private function resolveIdempotencyKey(CallbackPayload $payload, ?\Sujip\Esewa\ValueObject\ReferenceId $referenceId): string { - if ($referenceId !== null && $referenceId !== '') { - return 'ref:'.$referenceId; + if ($referenceId !== null) { + return 'ref:'.$referenceId->value(); } return 'digest:'.hash('sha256', $payload->data.'|'.$payload->signature); diff --git a/src/Service/SignatureService.php b/src/Service/SignatureService.php index a86eafa..c00eb79 100644 --- a/src/Service/SignatureService.php +++ b/src/Service/SignatureService.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace EsewaPayment\Service; +namespace Sujip\Esewa\Service; final class SignatureService { diff --git a/src/Support/FixedDelayRetryPolicy.php b/src/Support/FixedDelayRetryPolicy.php new file mode 100644 index 0000000..b96aa9f --- /dev/null +++ b/src/Support/FixedDelayRetryPolicy.php @@ -0,0 +1,34 @@ +maxRetries < 0) { + throw new \InvalidArgumentException('maxRetries cannot be negative.'); + } + + if ($this->delayUs < 0) { + throw new \InvalidArgumentException('delayUs cannot be negative.'); + } + } + + public function shouldRetry(int $attempt, TransportException $exception): bool + { + return $attempt < $this->maxRetries; + } + + public function delayUs(int $attempt, TransportException $exception): int + { + return $this->delayUs; + } +} diff --git a/src/Support/SystemClock.php b/src/Support/SystemClock.php new file mode 100644 index 0000000..ca561c5 --- /dev/null +++ b/src/Support/SystemClock.php @@ -0,0 +1,15 @@ +value; + } + + public function add(self $other): self + { + return self::fromFloat((float) $this->value + (float) $other->value); + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/ValueObject/ProductCode.php b/src/ValueObject/ProductCode.php new file mode 100644 index 0000000..424f7e5 --- /dev/null +++ b/src/ValueObject/ProductCode.php @@ -0,0 +1,38 @@ +value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/ValueObject/ReferenceId.php b/src/ValueObject/ReferenceId.php new file mode 100644 index 0000000..67a760b --- /dev/null +++ b/src/ValueObject/ReferenceId.php @@ -0,0 +1,38 @@ +value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/ValueObject/TransactionUuid.php b/src/ValueObject/TransactionUuid.php new file mode 100644 index 0000000..be70068 --- /dev/null +++ b/src/ValueObject/TransactionUuid.php @@ -0,0 +1,38 @@ +value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/Fakes/ArrayLogger.php b/tests/Fakes/ArrayLogger.php deleted file mode 100644 index f6657d7..0000000 --- a/tests/Fakes/ArrayLogger.php +++ /dev/null @@ -1,108 +0,0 @@ -}> */ - public array $records = []; - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function emergency($message, array $context = []): void - { - $this->log(LogLevel::EMERGENCY, (string) $message, $context); - } - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function alert($message, array $context = []): void - { - $this->log(LogLevel::ALERT, (string) $message, $context); - } - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function critical($message, array $context = []): void - { - $this->log(LogLevel::CRITICAL, (string) $message, $context); - } - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function error($message, array $context = []): void - { - $this->log(LogLevel::ERROR, (string) $message, $context); - } - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function warning($message, array $context = []): void - { - $this->log(LogLevel::WARNING, (string) $message, $context); - } - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function notice($message, array $context = []): void - { - $this->log(LogLevel::NOTICE, (string) $message, $context); - } - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function info($message, array $context = []): void - { - $this->log(LogLevel::INFO, (string) $message, $context); - } - - /** - * @param string|\Stringable $message - * @param array $context - */ - public function debug($message, array $context = []): void - { - $this->log(LogLevel::DEBUG, (string) $message, $context); - } - - /** - * @param string $level - * @param string|\Stringable $message - * @param array $context - */ - public function log($level, $message, array $context = []): void - { - $normalizedContext = []; - - foreach ($context as $key => $value) { - if (is_string($key)) { - $normalizedContext[$key] = $value; - } - } - - $this->records[] = [ - 'level' => (string) $level, - 'message' => (string) $message, - 'context' => $normalizedContext, - ]; - } -} diff --git a/tests/Fakes/FakeTransport.php b/tests/Fakes/FakeTransport.php index 47a0233..8806206 100644 --- a/tests/Fakes/FakeTransport.php +++ b/tests/Fakes/FakeTransport.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Fakes; +namespace Sujip\Esewa\Tests\Fakes; -use EsewaPayment\Contracts\TransportInterface; +use Sujip\Esewa\Contracts\TransportInterface; final class FakeTransport implements TransportInterface { diff --git a/tests/Fakes/FlakyTransport.php b/tests/Fakes/FlakyTransport.php index f280dda..cf49112 100644 --- a/tests/Fakes/FlakyTransport.php +++ b/tests/Fakes/FlakyTransport.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Fakes; +namespace Sujip\Esewa\Tests\Fakes; -use EsewaPayment\Contracts\TransportInterface; +use Sujip\Esewa\Contracts\TransportInterface; final class FlakyTransport implements TransportInterface { diff --git a/tests/Fakes/FrozenClock.php b/tests/Fakes/FrozenClock.php new file mode 100644 index 0000000..fc41368 --- /dev/null +++ b/tests/Fakes/FrozenClock.php @@ -0,0 +1,24 @@ +now; + } + + public function advanceSeconds(int $seconds): void + { + $this->now = $this->now->modify(sprintf('+%d seconds', $seconds)); + } +} diff --git a/tests/Stubs/OmnipayCommon.php b/tests/Stubs/OmnipayCommon.php new file mode 100644 index 0000000..f07d469 --- /dev/null +++ b/tests/Stubs/OmnipayCommon.php @@ -0,0 +1,234 @@ + + */ + public function getData(): array; + + public function send(): ResponseInterface; +} + +interface ResponseInterface +{ + public function isSuccessful(): bool; + + public function getData(): mixed; +} + +interface RedirectResponseInterface extends ResponseInterface +{ + public function isRedirect(): bool; + + public function getRedirectUrl(): string; + + public function getRedirectMethod(): string; + + /** + * @return array + */ + public function getRedirectData(): array; +} + +abstract class AbstractResponse implements ResponseInterface +{ + protected RequestInterface $request; + + /** + * @var mixed + */ + protected $data; + + public function getRequest(): RequestInterface + { + return $this->request; + } + + public function getData(): mixed + { + return $this->data; + } +} + +abstract class AbstractRequest implements RequestInterface +{ + /** + * @var array + */ + protected array $parameters = []; + + protected object $httpRequest; + + protected ?ResponseInterface $response = null; + + /** + * @param mixed $httpRequest + */ + public function __construct(mixed $httpClient = null, mixed $httpRequest = null) + { + unset($httpClient); + + $this->httpRequest = $httpRequest ?? new class { + public object $query; + public object $request; + + public function __construct() + { + $this->query = new class { + /** + * @return array + */ + public function all(): array + { + return []; + } + }; + $this->request = new class { + /** + * @return array + */ + public function all(): array + { + return []; + } + }; + } + }; + } + + /** + * @param array $parameters + */ + public function initialize(array $parameters = []): static + { + foreach ($parameters as $key => $value) { + $this->setParameter((string) $key, $value); + } + + return $this; + } + + public function getParameter(string $key): mixed + { + return $this->parameters[$key] ?? null; + } + + public function setParameter(string $key, mixed $value): static + { + $this->parameters[$key] = $value; + + return $this; + } + + public function getAmount(): ?string + { + $amount = $this->getParameter('amount'); + + return $amount === null ? null : (string) $amount; + } + + public function getReturnUrl(): ?string + { + $returnUrl = $this->getParameter('returnUrl'); + + return $returnUrl === null ? null : (string) $returnUrl; + } + + public function setReturnUrl(string $value): static + { + return $this->setParameter('returnUrl', $value); + } + + public function getTransactionId(): ?string + { + $transactionId = $this->getParameter('transactionId'); + + return $transactionId === null ? null : (string) $transactionId; + } + + public function getTestMode(): bool + { + return (bool) $this->getParameter('testMode'); + } + + public function send(): ResponseInterface + { + return $this->sendData($this->getData()); + } + + protected function validate(string ...$required): void + { + foreach ($required as $field) { + $value = $this->getParameter($field); + if ($value === null || $value === '') { + throw new \InvalidArgumentException(sprintf('The %s parameter is required.', $field)); + } + } + } + + abstract public function sendData(mixed $data): ResponseInterface; +} + +namespace Omnipay\Common; + +abstract class AbstractGateway +{ + /** + * @var array + */ + private array $parameters = []; + + /** + * @return array + */ + abstract public function getDefaultParameters(): array; + + /** + * @param class-string<\Omnipay\Common\Message\AbstractRequest> $class + * @param array $parameters + */ + protected function createRequest(string $class, array $parameters = []): \Omnipay\Common\Message\AbstractRequest + { + $request = new $class(); + + if (!$request instanceof \Omnipay\Common\Message\AbstractRequest) { + throw new \InvalidArgumentException('Invalid Omnipay request class.'); + } + + $request->initialize(array_replace($this->getDefaultParameters(), $this->parameters, $parameters)); + + return $request; + } + + protected function getParameter(string $key): mixed + { + return $this->parameters[$key] ?? $this->getDefaultParameters()[$key] ?? null; + } + + protected function setParameter(string $key, mixed $value): static + { + $this->parameters[$key] = $value; + + return $this; + } + + public function setTestMode(bool $value): static + { + return $this->setParameter('testMode', $value); + } + + public function getTestMode(): bool + { + return (bool) $this->getParameter('testMode'); + } + + public function setReturnUrl(string $value): static + { + return $this->setParameter('returnUrl', $value); + } +} diff --git a/tests/Unit/CallbackPayloadTest.php b/tests/Unit/CallbackPayloadTest.php index 48dbdf7..2147e5f 100644 --- a/tests/Unit/CallbackPayloadTest.php +++ b/tests/Unit/CallbackPayloadTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; +namespace Sujip\Esewa\Tests\Unit; -use EsewaPayment\Domain\Transaction\PaymentStatus; -use EsewaPayment\Domain\Verification\CallbackPayload; -use EsewaPayment\Exception\InvalidPayloadException; +use Sujip\Esewa\Domain\Transaction\PaymentStatus; +use Sujip\Esewa\Domain\Verification\CallbackPayload; +use Sujip\Esewa\Exception\InvalidPayloadException; use PHPUnit\Framework\TestCase; final class CallbackPayloadTest extends TestCase diff --git a/tests/Unit/CallbackServiceTest.php b/tests/Unit/CallbackServiceTest.php index 69970b8..f58aeea 100644 --- a/tests/Unit/CallbackServiceTest.php +++ b/tests/Unit/CallbackServiceTest.php @@ -2,16 +2,17 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; - -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; +namespace Sujip\Esewa\Tests\Unit; + +use Sujip\Esewa\Client\EsewaClient; +use Sujip\Esewa\Config\GatewayConfig; +use Sujip\Esewa\Domain\Transaction\PaymentStatus; +use Sujip\Esewa\Domain\Verification\CallbackPayload; +use Sujip\Esewa\Domain\Verification\VerificationExpectation; +use Sujip\Esewa\Domain\Verification\VerificationState; +use Sujip\Esewa\Exception\FraudValidationException; +use Sujip\Esewa\Service\SignatureService; +use Sujip\Esewa\Tests\Fakes\FakeTransport; use PHPUnit\Framework\TestCase; final class CallbackServiceTest extends TestCase @@ -52,6 +53,7 @@ public function testVerifyValidCallbackPayload(): void )); $this->assertTrue($result->valid); + $this->assertSame(VerificationState::VERIFIED, $result->state); $this->assertTrue($result->isSuccessful()); } @@ -76,6 +78,7 @@ public function testVerifyReturnsInvalidResultWhenSignatureIsWrong(): void $result = $gateway->callbacks()->verifyCallback($payload); $this->assertFalse($result->valid); + $this->assertSame(VerificationState::INVALID_SIGNATURE, $result->state); $this->assertSame(PaymentStatus::COMPLETE, $result->status); $this->assertSame('Invalid callback signature.', $result->message); $this->assertFalse($result->isSuccessful()); diff --git a/tests/Unit/CheckoutServiceTest.php b/tests/Unit/CheckoutServiceTest.php index 6099173..c0a7bf9 100644 --- a/tests/Unit/CheckoutServiceTest.php +++ b/tests/Unit/CheckoutServiceTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; +namespace Sujip\Esewa\Tests\Unit; -use EsewaPayment\Client\EsewaClient; -use EsewaPayment\Config\GatewayConfig; -use EsewaPayment\Domain\Checkout\CheckoutRequest; -use EsewaPayment\Tests\Fakes\FakeTransport; +use Sujip\Esewa\Client\EsewaClient; +use Sujip\Esewa\Esewa; +use Sujip\Esewa\Config\GatewayConfig; +use Sujip\Esewa\Domain\Checkout\CheckoutRequest; +use Sujip\Esewa\Tests\Fakes\FakeTransport; use PHPUnit\Framework\TestCase; final class CheckoutServiceTest extends TestCase @@ -41,4 +42,26 @@ public function testCreateIntentBuildsFormFields(): void $this->assertSame('TXN-1001', $form['fields']['transaction_uuid']); $this->assertNotSame('', $form['fields']['signature']); } + + public function testFactoryCanBootstrapWithoutExplicitTransportForCheckout(): void + { + $gateway = Esewa::make( + merchantCode: 'EPAYTEST', + secretKey: 'secret', + environment: 'uat', + ); + + $intent = $gateway->checkout()->createIntent(new CheckoutRequest( + amount: '100', + taxAmount: '0', + serviceCharge: '0', + deliveryCharge: '0', + transactionUuid: 'TXN-1002', + productCode: 'EPAYTEST', + successUrl: 'https://merchant.test/success', + failureUrl: 'https://merchant.test/failure', + )); + + $this->assertSame('TXN-1002', $intent->fields()['transaction_uuid']); + } } diff --git a/tests/Unit/DomainValidationTest.php b/tests/Unit/DomainValidationTest.php index 082715f..9908cb7 100644 --- a/tests/Unit/DomainValidationTest.php +++ b/tests/Unit/DomainValidationTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; +namespace Sujip\Esewa\Tests\Unit; -use EsewaPayment\Domain\Checkout\CheckoutRequest; -use EsewaPayment\Domain\Transaction\TransactionStatusRequest; +use Sujip\Esewa\Domain\Checkout\CheckoutRequest; +use Sujip\Esewa\Domain\Transaction\TransactionStatusRequest; use PHPUnit\Framework\TestCase; final class DomainValidationTest extends TestCase @@ -46,7 +46,7 @@ public function testCheckoutRequestThrowsWhenRequiredFieldMissing(): void public function testTransactionStatusRequestThrowsWhenRequiredFieldMissing(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('transactionUuid, totalAmount and productCode are required.'); + $this->expectExceptionMessage('transactionUuid is required.'); new TransactionStatusRequest( transactionUuid: '', diff --git a/tests/Unit/EndpointResolverTest.php b/tests/Unit/EndpointResolverTest.php index 4032c42..d698374 100644 --- a/tests/Unit/EndpointResolverTest.php +++ b/tests/Unit/EndpointResolverTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; +namespace Sujip\Esewa\Tests\Unit; -use EsewaPayment\Config\EndpointResolver; -use EsewaPayment\Config\GatewayConfig; +use Sujip\Esewa\Config\EndpointResolver; +use Sujip\Esewa\Config\GatewayConfig; use PHPUnit\Framework\TestCase; final class EndpointResolverTest extends TestCase diff --git a/tests/Unit/EsewaPaymentTest.php b/tests/Unit/EsewaTest.php similarity index 69% rename from tests/Unit/EsewaPaymentTest.php rename to tests/Unit/EsewaTest.php index 98dc063..6cc4957 100644 --- a/tests/Unit/EsewaPaymentTest.php +++ b/tests/Unit/EsewaTest.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; +namespace Sujip\Esewa\Tests\Unit; -use EsewaPayment\Client\EsewaClient; -use EsewaPayment\EsewaPayment; -use EsewaPayment\Tests\Fakes\FakeTransport; +use Sujip\Esewa\Client\EsewaClient; +use Sujip\Esewa\Esewa; +use Sujip\Esewa\Tests\Fakes\FakeTransport; use PHPUnit\Framework\TestCase; -final class EsewaPaymentTest extends TestCase +final class EsewaTest extends TestCase { public function testMakeCreatesClientWithMinimalSetup(): void { - $client = EsewaPayment::make( + $client = Esewa::make( merchantCode: 'EPAYTEST', secretKey: 'secret', transport: new FakeTransport([]), diff --git a/tests/Unit/GatewayConfigTest.php b/tests/Unit/GatewayConfigTest.php index 03583ef..39d22cf 100644 --- a/tests/Unit/GatewayConfigTest.php +++ b/tests/Unit/GatewayConfigTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; +namespace Sujip\Esewa\Tests\Unit; -use EsewaPayment\Config\Environment; -use EsewaPayment\Config\GatewayConfig; +use Sujip\Esewa\Config\Environment; +use Sujip\Esewa\Config\GatewayConfig; use PHPUnit\Framework\TestCase; final class GatewayConfigTest extends TestCase diff --git a/tests/Unit/IdempotencyStoreTest.php b/tests/Unit/IdempotencyStoreTest.php new file mode 100644 index 0000000..70231f4 --- /dev/null +++ b/tests/Unit/IdempotencyStoreTest.php @@ -0,0 +1,47 @@ +put('callback-1'); + + $this->assertTrue($store->has('callback-1')); + + $clock->advanceSeconds(11); + + $this->assertFalse($store->has('callback-1')); + } + + public function testPdoStoreExpiresKeys(): void + { + if (!extension_loaded('pdo_sqlite')) { + $this->markTestSkipped('pdo_sqlite is not available.'); + } + + $clock = new FrozenClock(new \DateTimeImmutable('2026-03-13 00:00:00 UTC')); + $store = new PdoIdempotencyStore(new PDO('sqlite::memory:'), ttlSeconds: 10, clock: $clock); + + $store->put('callback-1'); + + $this->assertTrue($store->has('callback-1')); + + $clock->advanceSeconds(11); + + $this->assertFalse($store->has('callback-1')); + } +} diff --git a/tests/Unit/NamespaceAliasTest.php b/tests/Unit/NamespaceAliasTest.php new file mode 100644 index 0000000..bdd3aba --- /dev/null +++ b/tests/Unit/NamespaceAliasTest.php @@ -0,0 +1,34 @@ +checkout()->createIntent(new CheckoutRequest( + amount: '100', + taxAmount: '0', + serviceCharge: '0', + deliveryCharge: '0', + transactionUuid: 'TXN-ALIAS-1', + productCode: 'EPAYTEST', + successUrl: 'https://merchant.test/success', + failureUrl: 'https://merchant.test/failure', + )); + + $this->assertSame('TXN-ALIAS-1', $intent->fields()['transaction_uuid']); + } +} diff --git a/tests/Unit/OmnipayBridgeTest.php b/tests/Unit/OmnipayBridgeTest.php new file mode 100644 index 0000000..19f2010 --- /dev/null +++ b/tests/Unit/OmnipayBridgeTest.php @@ -0,0 +1,165 @@ +setMerchantCode('EPAYTEST'); + $gateway->setSecretKey('secret'); + $gateway->setProductCode('EPAYTEST'); + $gateway->setTestMode(true); + $gateway->setReturnUrl('https://merchant.test/success'); + $gateway->setFailureUrl('https://merchant.test/failure'); + + $response = $gateway->purchase([ + 'amount' => '100', + 'transactionId' => 'TXN-2001', + ])->send(); + + $this->assertInstanceOf(PurchaseResponse::class, $response); + $this->assertFalse($response->isSuccessful()); + $this->assertTrue($response->isRedirect()); + $this->assertSame('POST', $response->getRedirectMethod()); + $this->assertStringContainsString('/v2/form', $response->getRedirectUrl()); + $this->assertSame('TXN-2001', $response->getRedirectData()['transaction_uuid']); + $this->assertSame('100.00', $response->getRedirectData()['total_amount']); + } + + public function testVerifyPaymentRequestMapsStatusResponse(): void + { + $transport = new FakeTransport([ + 'status' => 'COMPLETE', + 'ref_id' => 'REF-2001', + ]); + + $gateway = new SecureGateway(); + $gateway->setMerchantCode('EPAYTEST'); + $gateway->setSecretKey('secret'); + $gateway->setProductCode('EPAYTEST'); + $gateway->setTestMode(true); + + $response = $gateway->verifyPayment([ + 'amount' => '100.00', + 'transactionId' => 'TXN-2001', + 'transport' => $transport, + ])->send(); + + $this->assertInstanceOf(VerifyPaymentResponse::class, $response); + $this->assertTrue($response->isSuccessful()); + $this->assertSame('COMPLETE', $response->getResponseText()); + $this->assertSame('REF-2001', $response->getReferenceId()); + $this->assertSame('TXN-2001', $transport->lastQuery['transaction_uuid']); + } + + public function testCompletePurchaseRequestVerifiesCallbackPayload(): void + { + $request = new CompletePurchaseRequest(); + $request->initialize([ + 'merchantCode' => 'EPAYTEST', + 'secretKey' => 'secret', + 'productCode' => 'EPAYTEST', + 'transactionUuid' => 'TXN-2001', + 'totalAmount' => '100.00', + 'referenceNumber' => 'REF-2001', + ]); + + $data = [ + 'status' => 'COMPLETE', + 'transaction_uuid' => 'TXN-2001', + 'total_amount' => '100.00', + 'product_code' => 'EPAYTEST', + 'signed_field_names' => 'total_amount,transaction_uuid,product_code', + 'transaction_code' => 'REF-2001', + ]; + + $signature = (new SignatureService('secret'))->generate( + '100.00', + 'TXN-2001', + 'EPAYTEST', + 'total_amount,transaction_uuid,product_code' + ); + + $this->setHttpRequestPayload($request, [ + 'data' => base64_encode((string) json_encode($data)), + 'signature' => $signature, + ]); + + $response = $request->send(); + + $this->assertInstanceOf(CompletePurchaseResponse::class, $response); + $this->assertTrue($response->isSuccessful()); + $this->assertSame('REF-2001', $response->getTransactionReference()); + $this->assertSame('COMPLETE', $response->getData()['status']); + } + + public function testSecureGatewayCreatesExpectedRequestTypes(): void + { + $gateway = new SecureGateway(); + + $this->assertInstanceOf(PurchaseRequest::class, $gateway->purchase()); + $this->assertInstanceOf(CompletePurchaseRequest::class, $gateway->completePurchase()); + $this->assertInstanceOf(VerifyPaymentRequest::class, $gateway->verifyPayment()); + } + + /** + * @param array $payload + */ + private function setHttpRequestPayload(CompletePurchaseRequest $request, array $payload): void + { + $httpRequest = new class ($payload) { + public object $query; + public object $request; + + /** + * @param array $payload + */ + public function __construct(array $payload) + { + $this->query = new class { + /** + * @return array + */ + public function all(): array + { + return []; + } + }; + $this->request = new class ($payload) { + /** + * @param array $payload + */ + public function __construct(private readonly array $payload) + { + } + + /** + * @return array + */ + public function all(): array + { + return $this->payload; + } + }; + } + }; + + $reflection = new \ReflectionProperty($request, 'httpRequest'); + $reflection->setValue($request, $httpRequest); + } +} diff --git a/tests/Unit/ProductionHardeningTest.php b/tests/Unit/ProductionHardeningTest.php index 6cf0857..59b030c 100644 --- a/tests/Unit/ProductionHardeningTest.php +++ b/tests/Unit/ProductionHardeningTest.php @@ -2,32 +2,31 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; - -use EsewaPayment\Client\EsewaClient; -use EsewaPayment\Config\ClientOptions; -use EsewaPayment\Config\GatewayConfig; -use EsewaPayment\Domain\Transaction\PaymentStatus; -use EsewaPayment\Domain\Transaction\TransactionStatusRequest; -use EsewaPayment\Domain\Verification\CallbackPayload; -use EsewaPayment\Exception\TransportException; -use EsewaPayment\Infrastructure\Idempotency\InMemoryIdempotencyStore; -use EsewaPayment\Service\SignatureService; -use EsewaPayment\Tests\Fakes\ArrayLogger; -use EsewaPayment\Tests\Fakes\FakeTransport; -use EsewaPayment\Tests\Fakes\FlakyTransport; +namespace Sujip\Esewa\Tests\Unit; + +use Sujip\Esewa\Client\EsewaClient; +use Sujip\Esewa\Config\ClientOptions; +use Sujip\Esewa\Config\GatewayConfig; +use Sujip\Esewa\Domain\Transaction\PaymentStatus; +use Sujip\Esewa\Domain\Transaction\TransactionStatusRequest; +use Sujip\Esewa\Domain\Verification\CallbackPayload; +use Sujip\Esewa\Domain\Verification\VerificationState; +use Sujip\Esewa\Exception\TransportException; +use Sujip\Esewa\Infrastructure\Idempotency\InMemoryIdempotencyStore; +use Sujip\Esewa\Contracts\RetryPolicyInterface; +use Sujip\Esewa\Service\SignatureService; +use Sujip\Esewa\Tests\Fakes\FakeTransport; +use Sujip\Esewa\Tests\Fakes\FlakyTransport; use PHPUnit\Framework\TestCase; final class ProductionHardeningTest extends TestCase { public function testCallbackReplayProtectionRejectsDuplicatePayload(): void { - $logger = new ArrayLogger(); $store = new InMemoryIdempotencyStore(); $options = new ClientOptions( preventCallbackReplay: true, idempotencyStore: $store, - logger: $logger ); $gateway = new EsewaClient( @@ -52,14 +51,15 @@ public function testCallbackReplayProtectionRejectsDuplicatePayload(): void $second = $gateway->callbacks()->verifyCallback($payload); $this->assertTrue($first->valid); + $this->assertSame(VerificationState::VERIFIED, $first->state); $this->assertFalse($second->valid); + $this->assertSame(VerificationState::REPLAYED, $second->state); + $this->assertTrue($second->isReplayed()); $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'), @@ -69,7 +69,7 @@ public function testStatusCheckRetriesOnTransportErrorsAndEventuallySucceeds(): $gateway = new EsewaClient( GatewayConfig::make('EPAYTEST', 'secret', 'uat'), $transport, - new ClientOptions(maxStatusRetries: 2, statusRetryDelayMs: 0, logger: $logger) + new ClientOptions(maxStatusRetries: 2, statusRetryDelayMs: 0) ); $result = $gateway->transactions()->fetchStatus(new TransactionStatusRequest( @@ -80,7 +80,6 @@ public function testStatusCheckRetriesOnTransportErrorsAndEventuallySucceeds(): $this->assertSame(3, $transport->attempts); $this->assertSame(PaymentStatus::COMPLETE, $result->status); - $this->assertTrue($this->hasEvent($logger, 'esewa.status.retry')); } public function testStatusCheckThrowsWhenRetryLimitExceeded(): void @@ -105,14 +104,51 @@ public function testStatusCheckThrowsWhenRetryLimitExceeded(): void )); } - private function hasEvent(ArrayLogger $logger, string $event): bool + public function testStatusCheckUsesCustomRetryPolicy(): void { - foreach ($logger->records as $record) { - if (($record['context']['event'] ?? null) === $event) { - return true; + $transport = new FlakyTransport([ + new TransportException('temporary timeout'), + ['status' => 'COMPLETE', 'ref_id' => 'REF-OK'], + ]); + + $policy = new class implements RetryPolicyInterface { + public int $shouldRetryCalls = 0; + public int $delayCalls = 0; + + public function shouldRetry(int $attempt, TransportException $exception): bool + { + ++$this->shouldRetryCalls; + + return $attempt < 1; } - } - return false; + public function delayUs(int $attempt, TransportException $exception): int + { + ++$this->delayCalls; + + return 0; + } + }; + + $gateway = new EsewaClient( + GatewayConfig::make('EPAYTEST', 'secret', 'uat'), + $transport, + new ClientOptions( + maxStatusRetries: 0, + statusRetryDelayMs: 0, + retryPolicy: $policy, + ) + ); + + $result = $gateway->transactions()->fetchStatus(new TransactionStatusRequest( + transactionUuid: 'TXN-3003', + totalAmount: '500.00', + productCode: 'EPAYTEST' + )); + + $this->assertSame(1, $policy->shouldRetryCalls); + $this->assertSame(1, $policy->delayCalls); + $this->assertSame(2, $transport->attempts); + $this->assertSame(PaymentStatus::COMPLETE, $result->status); } } diff --git a/tests/Unit/SignatureServiceTest.php b/tests/Unit/SignatureServiceTest.php index 4189ddb..bd3bd2d 100644 --- a/tests/Unit/SignatureServiceTest.php +++ b/tests/Unit/SignatureServiceTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; +namespace Sujip\Esewa\Tests\Unit; -use EsewaPayment\Service\SignatureService; +use Sujip\Esewa\Service\SignatureService; use PHPUnit\Framework\TestCase; final class SignatureServiceTest extends TestCase diff --git a/tests/Unit/TransactionServiceTest.php b/tests/Unit/TransactionServiceTest.php index 3942c86..c2d2164 100644 --- a/tests/Unit/TransactionServiceTest.php +++ b/tests/Unit/TransactionServiceTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace EsewaPayment\Tests\Unit; +namespace Sujip\Esewa\Tests\Unit; -use EsewaPayment\Client\EsewaClient; -use EsewaPayment\Config\GatewayConfig; -use EsewaPayment\Domain\Transaction\PaymentStatus; -use EsewaPayment\Domain\Transaction\TransactionStatusRequest; -use EsewaPayment\Tests\Fakes\FakeTransport; +use Sujip\Esewa\Client\EsewaClient; +use Sujip\Esewa\Config\GatewayConfig; +use Sujip\Esewa\Domain\Transaction\PaymentStatus; +use Sujip\Esewa\Domain\Transaction\TransactionStatusRequest; +use Sujip\Esewa\Tests\Fakes\FakeTransport; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..755b98c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,9 @@ +