diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..e8f596e --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,30 @@ +name: Changelog + +on: + pull_request: + branches: [main] + paths: + - 'lib/**' + - 'tests/**' + - 'composer.json' + +jobs: + check: + runs-on: ubuntu-latest + name: Check CHANGELOG updated + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Verify CHANGELOG.md was updated + run: | + git fetch origin ${{ github.base_ref }} --depth=1 + if git diff --name-only origin/${{ github.base_ref }} HEAD | grep -q 'CHANGELOG.md'; then + echo "CHANGELOG.md was updated" + exit 0 + else + echo "ERROR: CHANGELOG.md was not updated. Please document your changes." + exit 1 + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d2c8132 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.1', '8.2', '8.3', '8.4'] + + name: PHP ${{ matrix.php-version }} + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v5 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run tests + run: composer test + + static-analysis: + runs-on: ubuntu-latest + name: Static Analysis + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPStan + run: composer phpstan + + code-style: + runs-on: ubuntu-latest + name: Code Style + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Check code style + run: composer cs-check \ No newline at end of file diff --git a/.github/workflows/pr-summary.yml b/.github/workflows/pr-summary.yml new file mode 100644 index 0000000..930c7b2 --- /dev/null +++ b/.github/workflows/pr-summary.yml @@ -0,0 +1,49 @@ +name: Auto Generate PR Summary + +on: + pull_request: + types: [opened, synchronize] + +jobs: + generate-summary: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install dependencies + run: pip install requests + + - name: Get PR Diff + run: | + git diff origin/${{ github.base_ref }}...HEAD > pr_diff.txt + + - name: Generate Summary with Ollama Cloud + id: ollama + env: + OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }} + GH_TOKEN: ${{ github.token }} + run: | + gh pr view ${{ github.event.pull_request.number }} --json body -q .body > current_body.md || touch current_body.md + python scripts/generate_pr_summary.py pr_diff.txt current_body.md > summary.md + + echo "SUMMARY<> $GITHUB_ENV + cat summary.md >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Update PR Description + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr edit ${{ github.event.pull_request.number }} --body "$SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9f90fdd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + create-release: + runs-on: ubuntu-latest + name: Create GitHub Release + + steps: + - uses: actions/checkout@v6 + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Extract release notes from CHANGELOG + id: notes + run: | + VERSION="${GITHUB_REF#refs/tags/v}" + # Extract section for this version from CHANGELOG.md + awk "/^## \\[$VERSION\\]/ {flag=1; next} /^## \\[/ {flag=0} flag" CHANGELOG.md > release_notes.md + if [ ! -s release_notes.md ]; then + echo "No release notes found for $VERSION in CHANGELOG.md" + exit 1 + fi + echo "notes<> "$GITHUB_OUTPUT" + cat release_notes.md >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ${{ steps.version.outputs.VERSION }} + body_path: release_notes.md + draft: false + prerelease: false diff --git a/.gitignore b/.gitignore index b2767ca..9c15e89 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ vendor/ # misc composer.lock .DS_Store +.php-cs-fixer.cache # text editor settings .vscode/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..5027aed --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,35 @@ +in(__DIR__ . '/lib') + ->in(__DIR__ . '/tests') + ->in(__DIR__ . '/examples'); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline' => true, + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => true, + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'class_attributes_separation' => [ + 'elements' => [ + 'method' => 'one', + ], + ], + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'single_trait_insert_per_statement' => true, + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41fa450 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,138 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] - 2026-05-14 + +### Added + +- Add custom exception hierarchy for API error handling (`ChipApiException`, `AuthenticationException`, `NotFoundException`, `ValidationException`, `ClientException`, `ServerException`) +- Add PSR-3 logger injection support for request/response observability +- Add configurable request timeout via constructor `$config` array +- Add `PurchaseBuilder` fluent API for constructing purchase objects +- Add PHPStan (level 8) and PHP-CS-Fixer configuration +- Add GitHub Actions CI workflow (tests on PHP 8.1–8.3, static analysis, code style) +- Add GitHub Actions PR summary automation via Ollama Cloud +- Add GitHub Actions changelog validation and release automation +- Expand test coverage: model mapping tests, exception handling tests, logger integration, timeout configuration, billing API tests, webhook verification tests +- Add new endpoints and models: Account (balance, turnover), PublicKey, Statements, Client CRUD, Webhook list/update, Purchase resend invoice +- Add `ClientRecurringToken`, `ClientRecurringTokenList`, `CompanyStatement`, `CompanyStatementList`, `WebhookList` models +- Add `Chip\Http\ClientInterface` internal HTTP abstraction with `GuzzleClient` implementation +- Add `RetryClient` decorator with exponential backoff for 429/5xx responses +- Add resource classes: `PurchasesResource`, `ClientsResource`, `WebhooksResource`, `PaymentMethodsResource`, `AccountResource`, `StatementsResource`, `PublicKeyResource`, `BillingResource` +- Add `fromArray()` static factory methods to all models replacing JsonMapper +- Add pagination iterators (`iterate()`, `iterateTemplates()`, `iterateClients()`) for list endpoints + +### Changed + +- **Bump PHP requirement from `>=7.2.0` to `^8.1`** +- **Rewrite `ChipApi` from trait-based architecture to resource-based architecture** (`$chip->purchases->create()` instead of `$chip->createPurchase()`) +- **Replace JsonMapper with typed `fromArray()` static factory methods on all models** +- **Add automatic retry with exponential backoff for 429 and 5xx responses** +- **Rewrite `ChipApi::request()` to catch Guzzle HTTP exceptions and throw domain-specific exceptions** +- Upgrade PHPUnit to ^10.5, PHPStan to ^2.1, PHP-CS-Fixer to ^3.95 +- Rewrite README with badges, quick-start, API reference, error handling docs +- Rewrite MIGRATION.md with resource API migration guide and pagination docs +- Add CONTRIBUTING.md with development workflow guidelines +- Update CLAUDE.md with new commands and architecture details + +### Removed + +- Remove `netresearch/jsonmapper` dependency +- Remove `Chip\Traits\Api\*` traits (`Purchase`, `PaymentMethod`, `Client`, `Webhook`, `Billing`, `PublicKey`, `Account`, `Statements`) + +### Fixed + +- Fix implicitly nullable parameter warnings in `Purchase` trait by using explicit nullable types (`?int`) +- Fix existing tests to pass correct types (string IDs, `Purchase` objects) +- Add property and return types to billing models and traits for PHPStan level 8 compliance +- Fix composer.json missing required `description` field for strict validation +- Fix model properties to match OpenAPI spec: `Product::quantity`, `Product::tax_percent` are now `string|null`; `Purchase::issued` is now `string|null`; `Purchase::status_history` is now `array` + +## [1.1.3] - 2024-03-12 + +### Fixed + +- Fix logo not appearing for get payment method +- Fix indentation in billing template client subscriber + +## [1.1.2] - 2024-02-13 + +### Added + +- Add billing traits and billing models +- Add `markAsPaid` method for purchases +- Add `BillingTemplateClientAddSubscriber` model +- Add sample test files and test cases + +### Changed + +- Update API methods and models +- Refactor billing-related code + +## [1.1.1] - 2023-04-06 + +### Added + +- Add webhook delete API + +### Changed + +- Amend PHP version requirement in README + +## [1.1.0] - 2023-04-05 + +### Added + +- Add webhooks API (create and get) +- Add `PaymentMethod` model + +### Changed + +- Refactor code for PHP 8 compatibility +- Bump PHP version requirement to at least 8.0.0 + +### Fixed + +- Fix `payment_method_whitelist` typo +- Ensure non-null values are included via `array_filter` + +## [1.0.1] - 2023-02-13 + +### Added + +- Add `force_recurring` property to `Purchase` model +- Add `createClient` method in `Client` trait + +### Changed + +- Load VCS instead of package to ensure `composer.json` file is readable +- Update README with installation instructions + +### Fixed + +- Add missing `require` for `chip-sdk-php` in composer examples + +## [1.0.0] - 2023-01-05 + +### Added + +- Initial release +- `ChipApi` client with `Purchase`, `PaymentMethod`, `Client`, and `Webhook` traits +- Models: `Purchase`, `PurchaseDetails`, `Product`, `ClientDetails`, `PaymentMethods`, `Webhook` +- `verify()` static method for webhook signature verification using RSA-SHA256 +- Basic test suite with Guzzle `MockHandler` + +[Unreleased]: https://github.com/CHIPAsia/chip-php-sdk/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/CHIPAsia/chip-php-sdk/compare/v1.1.3...v2.0.0 +[1.1.3]: https://github.com/CHIPAsia/chip-php-sdk/compare/v1.1.2...v1.1.3 +[1.1.2]: https://github.com/CHIPAsia/chip-php-sdk/compare/v1.1.1...v1.1.2 +[1.1.1]: https://github.com/CHIPAsia/chip-php-sdk/compare/v1.1.0...v1.1.1 +[1.1.0]: https://github.com/CHIPAsia/chip-php-sdk/compare/v1.0.1...v1.1.0 +[1.0.1]: https://github.com/CHIPAsia/chip-php-sdk/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/CHIPAsia/chip-php-sdk/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3b06886 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +- Install dependencies: `composer install` +- Run all tests: `composer test` (or `./vendor/bin/phpunit tests`) +- Run a single test: `./vendor/bin/phpunit tests/ApiTest.php --filter testRefundWithoutAmount` +- Run static analysis: `composer phpstan` +- Fix code style: `composer cs-fix` +- Check code style without fixing: `composer cs-check` + +## Architecture + +`ChipApi` (`lib/ChipApi.php`) is the main client class. It composes API functionality through four traits in `lib/Traits/Api/`: + +- `Purchase` — create, get, cancel, release, capture, charge, refund purchases, and delete recurring tokens +- `PaymentMethod` — list available payment methods for a brand/currency +- `Client` — create clients +- `Webhook` — create and get webhooks + +All API methods use the protected `request()` helper on `ChipApi`, which sends authenticated Guzzle HTTP requests and returns decoded JSON. Results are mapped to model objects via `JsonMapper` (`netresearch/jsonmapper`). + +### Error Handling + +`request()` catches Guzzle HTTP exceptions and throws domain-specific exceptions in `lib/Exception/`: + +- `AuthenticationException` — 401 responses +- `NotFoundException` — 404 responses +- `ValidationException` — 422 responses (exposes validation errors via `getErrors()`) +- `ServerException` — 5xx responses +- `ClientException` — other 4xx responses + +All exceptions extend `ChipApiException`, which exposes the decoded response body via `getResponseBody()`. + +### Models + +Models live in `lib/Model/` and are plain POPO classes with public properties, implementing `JsonSerializable`. Serialization strips null values via `array_filter((array) $this)`. The main models are `Purchase`, `PurchaseDetails`, `Product`, `ClientDetails`, `PaymentMethods`, and `Webhook`. + +### Builders + +`PurchaseBuilder` (`lib/Builder/PurchaseBuilder.php`) provides a fluent API for constructing `Purchase` objects without manually nesting `ClientDetails`, `PurchaseDetails`, and `Product` instances. + +### Logging & Configuration + +`ChipApi` accepts an optional `Psr\Log\LoggerInterface` for request/response logging, and an optional `timeout` key in the `$config` array (defaults to 30 seconds). + +### Testing + +Tests in `tests/ApiTest.php` use Guzzle's `MockHandler` and `Middleware::history()` to intercept and assert on HTTP requests without making real network calls. The helper `getMockApi()` constructs a `ChipApi` instance injected with a custom Guzzle handler stack. + +### Webhook Verification + +`ChipApi::verify()` is a static utility that verifies RSA-SHA256 signatures on webhook/callback payloads using `openssl_verify`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..29ab001 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to CHIP PHP SDK + +Thank you for your interest in contributing! We welcome bug reports, feature requests, and pull requests. + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone git@github.com:your-username/chip-php-sdk.git` +3. Install dependencies: `composer install` +4. Run tests: `composer test` + +## Development Workflow + +### Running Tests + +```bash +# Run all tests +composer test + +# Run a specific test +./vendor/bin/phpunit tests/ApiTest.php --filter testCreatePurchase +``` + +### Static Analysis + +We use PHPStan at level 8. All code must pass: + +```bash +composer phpstan +``` + +### Code Style + +We use PHP-CS-Fixer with the PSR-12 preset. Check style before committing: + +```bash +composer cs-check +``` + +Auto-fix issues: + +```bash +composer cs-fix +``` + +If you need to run php-cs-fixer on PHP 8.0 (e.g., for CI compatibility): + +```bash +docker run --rm -v "$(pwd)":/app -w /app php:8.0-cli bash -c " + ./vendor/bin/php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.dist.php +" +``` + +## Submitting Changes + +1. Create a feature branch: `git checkout -b feature/my-feature` +2. Make your changes with tests +3. Ensure all tests pass: `composer test` +4. Ensure PHPStan passes: `composer phpstan` +5. Ensure code style is clean: `composer cs-check` +6. Commit with a clear message +7. Push and open a Pull Request + +## Reporting Bugs + +When reporting bugs, please include: + +- PHP version +- SDK version +- Steps to reproduce +- Expected vs actual behavior +- Stack trace if applicable + +## Code of Conduct + +Be respectful and constructive in all interactions. diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..0fbbc8c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,242 @@ +# Migration Guide + +## Upgrading from 1.x to 2.0.0 + +### PHP Version Requirement + +The minimum PHP version has been raised from `>=7.2.0` to `^8.1`. + +If your project runs on PHP 7.2–8.0, you must upgrade your runtime before installing version 2.0.0. + +### Exception Handling + +In 1.x, Guzzle HTTP exceptions bubbled up directly. In 2.0.0, `ChipApi` catches all HTTP errors and throws domain-specific exceptions. + +**Before (1.x):** + +```php +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\ServerException; + +try { + $purchase = $chip->getPurchase('nonexistent_id'); +} catch (ClientException $e) { + $statusCode = $e->getResponse()->getStatusCode(); + $body = json_decode((string) $e->getResponse()->getBody(), true); +} +``` + +**After (2.0.0):** + +```php +use Chip\Exception\AuthenticationException; +use Chip\Exception\NotFoundException; +use Chip\Exception\ValidationException; +use Chip\Exception\ServerException; +use Chip\Exception\ClientException; + +try { + $purchase = $chip->purchases->get('nonexistent_id'); +} catch (NotFoundException $e) { + $statusCode = $e->getCode(); // 404 + $body = $e->getResponseBody(); // decoded array +} catch (ValidationException $e) { + $errors = $e->getErrors(); // 422 validation errors +} catch (AuthenticationException $e) { + // 401 - invalid API key +} catch (ServerException $e) { + // 5xx - server error +} catch (ClientException $e) { + // other 4xx errors +} +``` + +All exceptions extend `Chip\Exception\ChipApiException`, which exposes the decoded response body via `getResponseBody()`. + +### Model Property Type Changes + +Several model properties changed types to match the CHIP API specification. + +#### `Product` + +| Property | Before | After | +|----------|--------|-------| +| `$quantity` | `float` | `string\|null` | +| `$tax_percent` | `float` | `string\|null` | + +**Before (1.x):** + +```php +$product = new \Chip\Model\Product(); +$product->name = 'Widget'; +$product->price = 5000; +$product->quantity = 2.0; +$product->tax_percent = 0.06; +$total = $product->price * $product->quantity; // works +``` + +**After (2.0.0):** + +```php +$product = new \Chip\Model\Product(); +$product->name = 'Widget'; +$product->price = 5000; +$product->quantity = '2.0'; // now a string +$product->tax_percent = '0.06'; // now a string +$total = $product->price * (float) $product->quantity; // cast needed +``` + +> The API returns `quantity` and `tax_percent` as strings (e.g. `"1.0000"`, `"0.00"`), so the model now reflects the actual response format. + +#### `Purchase` + +| Property | Before | After | +|----------|--------|-------| +| `$issued` | `int` | `string\|null` | +| `$status_history` | `object` | `array` | + +**Before (1.x):** + +```php +$issuedTimestamp = $purchase->issued; // int +$status = $purchase->status_history->status; // object access +``` + +**After (2.0.0):** + +```php +$issuedString = $purchase->issued; // string or null +$status = $purchase->status_history[0]->status; // array access +``` + +#### `PaymentMethods` + +| Property | Before | After | +|----------|--------|-------| +| `$by_country` | `string[][]` | `array` (key-value map) | +| `$country_names` | `string[]` | `array` (key-value map) | +| `$names` | `string[]` | `array` (key-value map) | + +These are associative arrays, not sequential. Access remains the same (`$methods->names['fpx']`), but type checks may differ. + +### Resource-Based Client API + +The biggest architectural change in 2.0.0 is the move from monolithic trait-based methods on `ChipApi` to dedicated resource objects: + +**Before (1.x):** + +```php +$purchase = $chip->createPurchase($purchase); +$client = $chip->createClient($clientDetails); +$webhook = $chip->createWebhook($webhook); +$methods = $chip->getPaymentMethods('MYR'); +$balance = $chip->getBalance(); +``` + +**After (2.0.0):** + +```php +$purchase = $chip->purchases->create($purchase); +$client = $chip->clients->create($clientDetails); +$webhook = $chip->webhooks->create($webhook); +$methods = $chip->paymentMethods->list('MYR'); +$balance = $chip->account->balance(); +``` + +Available resources: + +| Resource | Old Method | New Method | +|----------|-----------|------------| +| `purchases` | `createPurchase()` | `create()` | +| `purchases` | `getPurchase()` | `get()` | +| `purchases` | `cancelPurchase()` | `cancel()` | +| `purchases` | `releasePurchase()` | `release()` | +| `purchases` | `capturePurchase()` | `capture()` | +| `purchases` | `chargePurchase()` | `charge()` | +| `purchases` | `refundPurchase()` | `refund()` | +| `purchases` | `deleteRecurringToken()` | `deleteRecurringToken()` | +| `purchases` | `markAsPaid()` | `markAsPaid()` | +| `purchases` | `resendInvoice()` | `resendInvoice()` | +| `clients` | `createClient()` | `create()` | +| `clients` | `getClient()` | `get()` | +| `clients` | `getClients()` | `list()` | +| `clients` | `updateClient()` | `update()` | +| `clients` | `partialUpdateClient()` | `partialUpdate()` | +| `clients` | `deleteClient()` | `delete()` | +| `clients` | `listRecurringTokens()` | `listRecurringTokens()` | +| `clients` | `getRecurringToken()` | `getRecurringToken()` | +| `clients` | `deleteRecurringTokenByClient()` | `deleteRecurringToken()` | +| `webhooks` | `createWebhook()` | `create()` | +| `webhooks` | `getWebhook()` | `get()` | +| `webhooks` | `listWebhooks()` | `list()` | +| `webhooks` | `updateWebhook()` | `update()` | +| `webhooks` | `partialUpdateWebhook()` | `partialUpdate()` | +| `webhooks` | `deleteWebhook()` | `delete()` | +| `paymentMethods` | `getPaymentMethods()` | `list()` | +| `publicKey` | `getPublicKey()` | `get()` | +| `account` | `getBalance()` | `balance()` | +| `account` | `getTurnover()` | `turnover()` | +| `statements` | `scheduleStatement()` | `schedule()` | +| `statements` | `listStatements()` | `list()` | +| `statements` | `getStatement()` | `get()` | +| `statements` | `cancelStatement()` | `cancel()` | +| `billing` | `createBilling()` | `create()` | +| `billing` | `createBillingTemplate()` | `createTemplate()` | +| `billing` | `getBillingTemplates()` | `listTemplates()` | +| `billing` | `getBillingTemplate()` | `getTemplate()` | +| `billing` | `updateBillingTemplate()` | `updateTemplate()` | +| `billing` | `deleteBillingTemplate()` | `deleteTemplate()` | +| `billing` | `sendBillingTemplateInvoice()` | `sendInvoice()` | +| `billing` | `addBillingTemplateSubscriber()` | `addSubscriber()` | +| `billing` | `getBillingTemplateClients()` | `listClients()` | +| `billing` | `getBillingTemplateClient()` | `getClient()` | +| `billing` | `updateBillingTemplateClient()` | `updateClient()` | + +### Pagination Iterators + +List endpoints now support automatic pagination iterators: + +```php +foreach ($chip->clients->iterate() as $client) { + echo $client->email; +} + +foreach ($chip->webhooks->iterate() as $webhook) { + echo $webhook->title; +} + +foreach ($chip->billing->iterateTemplates() as $template) { + echo $template->title; +} +``` + +### New Optional Constructor Parameter + +`ChipApi` now accepts an optional PSR-3 logger as the 5th parameter: + +```php +$chip = new ChipApi( + brandId: 'YOUR_BRAND_ID', + apiKey: 'YOUR_API_KEY', + base: 'https://gate.chip-in.asia/api/v1/', + config: ['timeout' => 30], + logger: $psr3Logger // optional, new in 2.0.0 +); +``` + +Existing 3-argument constructor calls remain backward-compatible. + +### New Features Available + +Version 2.0.0 adds several new endpoints and helpers that were not available in 1.x: + +- `PurchaseBuilder` fluent API +- `Account` endpoints: `$chip->account->balance()`, `$chip->account->turnover()` +- `PublicKey` endpoint: `$chip->publicKey->get()` +- `Statements` endpoints: `$chip->statements->schedule()`, `$chip->statements->list()`, `$chip->statements->get()`, `$chip->statements->cancel()` +- Expanded `Client` endpoints: `$chip->clients->get()`, `$chip->clients->update()`, `$chip->clients->partialUpdate()`, `$chip->clients->delete()`, `$chip->clients->listRecurringTokens()`, `$chip->clients->getRecurringToken()`, `$chip->clients->deleteRecurringToken()` +- Expanded `Webhook` endpoints: `$chip->webhooks->list()`, `$chip->webhooks->update()`, `$chip->webhooks->partialUpdate()` +- `$chip->purchases->resendInvoice()` +- Automatic retry with exponential backoff for 429 and 5xx responses + +These are purely additive — no existing code needs to change unless you want to use them. diff --git a/README.md b/README.md index c865807..e1e79f8 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,406 @@ -# Chip PHP library +# CHIP PHP SDK + +[![CI](https://github.com/CHIPAsia/chip-php-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/CHIPAsia/chip-php-sdk/actions) + + +Official PHP SDK for [CHIP](https://chip-in.asia) payment platform. ## Requirements -PHP 8.0 and later. +- PHP ^8.1 +- Extensions: `curl`, `json`, `openssl` -The following PHP extensions are required: +## Upgrading from 1.x -* curl -* json -* openssl +See [MIGRATION.md](MIGRATION.md) for a detailed guide on breaking changes when upgrading from 1.x to 2.0.0. ## Prerequisite -Before you start, make sure you already have created `Brand ID` and `API Key` from your developer dashboard by logging-in into [merchant portal](https://gate.chip-in.asia/login). +Before you start, make sure you already have created `Brand ID` and `API Key` from your developer dashboard by logging-in into [merchant portal](https://gate.chip-in.asia/login). ## Installation -## Composer - -Add the following on your `composer.json` -```php -"repositories": [ - ... - { - "type": "vcs", - "url": "git@github.com:CHIPAsia/chip-php-sdk.git" - } -], -"require": { - "chip/chip-sdk-php": "^1.0" + +The package is not yet published on Packagist. Install via VCS repository: + +```bash +composer config repositories.chip-sdk vcs https://github.com/CHIPAsia/chip-php-sdk.git +composer require chip/chip-sdk-php:^2.0 +``` + +## Quick Start + +```php +use Chip\ChipApi; +use Chip\Builder\PurchaseBuilder; + +$chip = new ChipApi('YOUR_BRAND_ID', 'YOUR_API_KEY'); + +$purchase = PurchaseBuilder::create() + ->brandId('YOUR_BRAND_ID') + ->currency('MYR') + ->language('en') + ->clientEmail('customer@example.com') + ->clientFullName('John Doe') + ->addProduct('Widget', 5000, 2) + ->successRedirect('https://yourdomain.com/success') + ->failureRedirect('https://yourdomain.com/failure') + ->successCallback('https://yourdomain.com/webhook') + ->build(); + +$result = $chip->purchases->create($purchase); + +if ($result->checkout_url) { + header('Location: ' . $result->checkout_url); + exit; } ``` -And run command -```bash -composer install -# OR -composer update +## Authentication + +You need a `Brand ID` and `API Key` from the [CHIP Merchant Portal](https://gate.chip-in.asia/login). + +```php +$chip = new ChipApi('YOUR_BRAND_ID', 'YOUR_API_KEY'); ``` -## Functions +Optional parameters: + +```php +$chip = new ChipApi( + brandId: 'YOUR_BRAND_ID', + apiKey: 'YOUR_API_KEY', + base: 'https://gate.chip-in.asia/api/v1/', // optional, default shown + config: ['timeout' => 30], // optional Guzzle config + logger: $psr3Logger // optional PSR-3 logger +); +``` + +## API Methods + +The SDK is organized into resource objects accessed via properties on `ChipApi`: + +- `$chip->purchases` +- `$chip->clients` +- `$chip->webhooks` +- `$chip->paymentMethods` +- `$chip->account` +- `$chip->statements` +- `$chip->publicKey` +- `$chip->billing` + +### Purchases + +```php +// Create a purchase +$purchase = $chip->purchases->create($purchaseModel); + +// Get purchase details +$purchase = $chip->purchases->get('purchase_id'); + +// Cancel a purchase +$purchase = $chip->purchases->cancel('purchase_id'); + +// Release a purchase +$purchase = $chip->purchases->release('purchase_id'); + +// Capture payment (full or partial) +$purchase = $chip->purchases->capture('purchase_id'); +$purchase = $chip->purchases->capture('purchase_id', 5000); // partial + +// Refund (full or partial) +$purchase = $chip->purchases->refund('purchase_id'); +$purchase = $chip->purchases->refund('purchase_id', 2500); // partial + +// Charge with recurring token +$purchase = $chip->purchases->charge('purchase_id', 'recurring_token'); + +// Delete recurring token +$purchase = $chip->purchases->deleteRecurringToken('purchase_id'); + +// Resend invoice +$purchase = $chip->purchases->resendInvoice('purchase_id'); +``` + +### Payment Methods + +```php +$methods = $chip->paymentMethods->list('MYR'); + +// Optional filters +$methods = $chip->paymentMethods->list('MYR', [ + 'country' => 'MY', + 'recurring' => true, + 'amount' => 500, +]); +``` + +### Clients + +```php +// Create a client +$client = new \Chip\Model\ClientDetails(); +$client->email = 'customer@example.com'; +$client->full_name = 'John Doe'; +$created = $chip->clients->create($client); + +// List all clients +$clients = $chip->clients->list(); + +// Iterate all clients (auto-paginates) +foreach ($chip->clients->iterate() as $client) { + echo $client->email; +} + +// Retrieve a client +$client = $chip->clients->get($clientId); + +// Update a client +$updated = $chip->clients->update($clientId, $client); + +// Partially update a client +$updated = $chip->clients->partialUpdate($clientId, $client); + +// Delete a client +$chip->clients->delete($clientId); + +// List recurring tokens for a client +$tokens = $chip->clients->listRecurringTokens($clientId); + +// Get a specific recurring token +$token = $chip->clients->getRecurringToken($clientId, $purchaseId); + +// Delete a recurring token +$chip->clients->deleteRecurringToken($clientId, $purchaseId); +``` + +### Webhooks + +```php +// List all webhooks +$webhooks = $chip->webhooks->list(); + +// Iterate all webhooks (auto-paginates) +foreach ($chip->webhooks->iterate() as $webhook) { + echo $webhook->title; +} + +// Create a webhook +$webhook = new \Chip\Model\Webhook(); +$webhook->title = 'My Webhook'; +$webhook->callback = 'https://yourdomain.com/webhook'; +$created = $chip->webhooks->create($webhook); + +// Get webhook details +$webhook = $chip->webhooks->get($webhookId); -**getPaymentMethods** +// Update a webhook +$updated = $chip->webhooks->update($webhookId, $webhook); + +// Partially update a webhook +$updated = $chip->webhooks->partialUpdate($webhookId, $webhook); + +// Delete a webhook +$chip->webhooks->delete($webhookId); ``` -Get list of payment methods that available for your account. + +### Account + +```php +// Get account balance (with optional filters) +$balance = $chip->account->balance(); +$balance = $chip->account->balance(['currency' => 'MYR']); + +// Get account turnover +$turnover = $chip->account->turnover(['from' => 1609459200, 'to' => 1640995200]); ``` -**createPurchase** +### Statements + +```php +// Schedule a company statement +$statement = new \Chip\Model\CompanyStatement(); +$statement->format = 'csv'; +$scheduled = $chip->statements->schedule($statement); + +// List statements +$statements = $chip->statements->list(); + +// Iterate statements (auto-paginates) +foreach ($chip->statements->iterate() as $statement) { + echo $statement->format; +} + +// Get a statement +$statement = $chip->statements->get($statementId); + +// Cancel a statement +$statement = $chip->statements->cancel($statementId); ``` -Create checkout & direct post URL. + +### Public Key + +```php +$publicKey = $chip->publicKey->get(); ``` -**getPurchase** +### Billing + +```php +// Create a billing template +$template = new \Chip\Model\Billing\BillingTemplate(); +$template->title = 'Monthly Subscription'; +$created = $chip->billing->createTemplate($template); + +// List billing templates +$templates = $chip->billing->listTemplates(); + +// Iterate billing templates (auto-paginates) +foreach ($chip->billing->iterateTemplates() as $template) { + echo $template->title; +} + +// Get a billing template +$template = $chip->billing->getTemplate($templateId); + +// Update a billing template +$updated = $chip->billing->updateTemplate($templateId, $template); + +// Delete a billing template +$chip->billing->deleteTemplate($templateId); + +// Send an invoice from a billing template +$client = new \Chip\Model\Billing\BillingTemplateClient(); +$client->client_id = $clientId; +$purchase = $chip->billing->sendInvoice($templateId, $client); + +// Add a subscriber +$result = $chip->billing->addSubscriber($templateId, $client); + +// List billing template clients +$clients = $chip->billing->listClients($templateId); + +// Iterate billing template clients (auto-paginates) +foreach ($chip->billing->iterateClients($templateId) as $client) { + echo $client->client_id; +} + +// Get a billing template client +$client = $chip->billing->getClient($templateId, $clientId); + +// Update a billing template client +$updated = $chip->billing->updateClient($templateId, $clientId, $client); ``` -Get purchase detail by its ID + +## Error Handling + +The SDK throws domain-specific exceptions: + +```php +use Chip\Exception\AuthenticationException; +use Chip\Exception\NotFoundException; +use Chip\Exception\ValidationException; +use Chip\Exception\ServerException; +use Chip\Exception\ClientException; + +try { + $purchase = $chip->purchases->get('nonexistent_id'); +} catch (NotFoundException $e) { + // 404 - Purchase not found + echo $e->getMessage(); +} catch (AuthenticationException $e) { + // 401 - Invalid API key +} catch (ValidationException $e) { + // 422 - Validation failed + print_r($e->getErrors()); +} catch (ServerException $e) { + // 5xx - Server error +} catch (ClientException $e) { + // Other 4xx errors +} ``` -**verify** +All exceptions extend `ChipApiException` and expose the response body: + +```php +try { + $chip->purchases->create($purchase); +} catch (ChipApiException $e) { + $statusCode = $e->getCode(); + $responseBody = $e->getResponseBody(); +} ``` -Verify callback or webhook response from our server. For webhook public key, it is generated when you register your webhook URL. Refer to our API (https://developer.chip-in.asia/api) for more information. + +## Webhook Verification + +Verify webhook signatures using your public key: + +```php +$isValid = ChipApi::verify($jsonPayload, $signatureHeader, $publicKey); ``` -And more which you can refer to [`/lib/ChipApi.php`](./lib/ChipApi.php) for more information. +## Purchase Builder + +The `PurchaseBuilder` provides a fluent API for constructing purchases: + +```php +use Chip\Builder\PurchaseBuilder; -## Getting Started +$purchase = PurchaseBuilder::create() + ->brandId('YOUR_BRAND_ID') + ->currency('MYR') + ->language('en') + ->clientEmail('customer@example.com') + ->clientFullName('John Doe') + ->clientPhone('+60123456789') + ->addProduct('Widget', 5000, 2) + ->addProduct('Gadget', 3000) + ->successRedirect('https://yourdomain.com/success') + ->failureRedirect('https://yourdomain.com/failure') + ->successCallback('https://yourdomain.com/webhook') + ->cancelRedirect('https://yourdomain.com/cancel') + ->build(); +``` -Simple usage looks like: +## Logging +Pass a PSR-3 compatible logger to enable request/response logging: ```php -email = 'test@example.com'; - $purchase = new \Chip\Model\Purchase(); - $purchase->client = $client; - $details = new \Chip\Model\PurchaseDetails(); - $product = new \Chip\Model\Product(); - $product->name = 'Test'; - $product->price = 100; - $details->products = [$product]; - $purchase->purchase = $details; - $purchase->brand_id = $config['brand_id']; - $purchase->success_redirect = 'https://yourdomain.com/redirect.php?success=1'; - $purchase->success_callback = 'https://yourdomain.com/callback.php?success=0'; - $purchase->failure_redirect = 'https://yourdomain.com/redirect.php?success=0'; +use Monolog\Logger; +use Monolog\Handler\StreamHandler; - $result = $chip->createPurchase($purchase); +$logger = new Logger('chip'); +$logger->pushHandler(new StreamHandler('chip.log')); - if ($result && $result->checkout_url) { - // Redirect user to checkout - header("Location: " . $result->checkout_url); - exit; - } +$chip = new ChipApi('BRAND_ID', 'API_KEY', logger: $logger); ``` -## Testing +## Development ```bash -./vendor/bin/phpunit tests +# Install dependencies +composer install + +# Run tests +composer test + +# Run static analysis +composer phpstan + +# Check code style +composer cs-check + +# Fix code style +composer cs-fix ``` -## Example -Check our examples [here](./examples). +## Contributing + +Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## License + +MIT License. See [LICENSE](LICENSE) for details. diff --git a/composer.json b/composer.json index 65a5f05..9cf5df9 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,32 @@ { "name": "chip/chip-sdk-php", + "description": "PHP SDK for CHIP Payments API", "type": "library", "require": { - "php": ">=8.0.0", - "guzzlehttp/guzzle": "^7.0", - "netresearch/jsonmapper": "^4.0" + "php": "^8.1", + "guzzlehttp/guzzle": "^7.9", + "psr/log": "^3.0" }, "license": "MIT", - "autoload": { + "autoload": { "psr-4": { "Chip\\": "lib" } }, "require-dev": { - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^2.1", + "friendsofphp/php-cs-fixer": "^3.95" + }, + "scripts": { + "test": "phpunit tests", + "phpstan": "phpstan analyse", + "cs-fix": "php-cs-fixer fix", + "cs-check": "php-cs-fixer fix --dry-run --diff" + }, + "config": { + "platform": { + "php": "8.1.0" + } } } diff --git a/examples/README.md b/examples/README.md index bc80005..4dfd13f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,7 @@ --- ## Requirements -* It is required `PHP` version >= 7.2 +* It is required `PHP` version >= 8.1 * `composer` to install dependencies ## Prerequisite @@ -21,11 +21,11 @@ You will need to replace the value on file [config.php](./config.php) with the c '<>', - 'api_key' => '<>', - 'endpoint' => 'https://gate.chip-in.asia/api/v1/', - 'basedUrl' => '<>', - 'webhook_public_key' => "< '<>', + 'api_key' => '<>', + 'endpoint' => 'https://gate.chip-in.asia/api/v1/', + 'basedUrl' => '<>', + 'webhook_public_key' => "<>" // SHOULD BE WRAPPED IN DOUBLE QUOTES (") ]; ``` diff --git a/examples/api/account_balance.php b/examples/api/account_balance.php new file mode 100644 index 0000000..274d647 --- /dev/null +++ b/examples/api/account_balance.php @@ -0,0 +1,11 @@ +getBalance(); + +echo "
" . json_encode($balance, JSON_PRETTY_PRINT) . "
"; diff --git a/examples/api/callback.php b/examples/api/callback.php index e7b25c7..e2c268a 100644 --- a/examples/api/callback.php +++ b/examples/api/callback.php @@ -1,27 +1,20 @@ getPublicKey(); - # GET PUBLIC KEY - $url = $config['endpoint'] . "public_key/"; - $curl = curl_init($url); - curl_setopt($curl, CURLOPT_URL, $url); - curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - $headers = array( - "Authorization: Bearer " . $config['api_key'], - ); - curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); - $publicKey = json_decode(curl_exec($curl)); - curl_close($curl); +$post = file_get_contents('php://input'); +$headers = getallheaders(); +$xSignature = $headers['X-Signature']; - $verify = \Chip\ChipApi::verify($post, $xSignature, $publicKey); - error_log("/callback VERIFIED: " . ($verify ? "true" : "false")); +$verify = \Chip\ChipApi::verify($post, $xSignature, $publicKey); + +$data = json_decode($post); +error_log('/callback EVENT: ' . $data->event_type); +error_log('/callback VERIFIED: ' . ($verify ? 'true' : 'false')); diff --git a/examples/api/client_crud.php b/examples/api/client_crud.php new file mode 100644 index 0000000..386a1f8 --- /dev/null +++ b/examples/api/client_crud.php @@ -0,0 +1,28 @@ +email = 'client@example.com'; +$client->full_name = 'John Doe'; + +$created = $chip->createClient($client); +echo "

Created Client

"; +echo "
" . json_encode($created, JSON_PRETTY_PRINT) . "
"; + +# Get all clients +$clients = $chip->getClients(); +echo "

All Clients

"; +echo "
" . json_encode($clients, JSON_PRETTY_PRINT) . "
"; + +# Update client +$clientId = $created->id; +$client->full_name = 'Jane Doe'; +$updated = $chip->updateClient($clientId, $client); +echo "

Updated Client

"; +echo "
" . json_encode($updated, JSON_PRETTY_PRINT) . "
"; diff --git a/examples/api/create_purchase.php b/examples/api/create_purchase.php index fcb4595..e1a676e 100644 --- a/examples/api/create_purchase.php +++ b/examples/api/create_purchase.php @@ -24,7 +24,7 @@ $result = $chip->createPurchase($purchase); if ($result && $result->checkout_url) { - // Redirect user to checkout - header("Location: " . $result->checkout_url); - exit; -} \ No newline at end of file + // Redirect user to checkout + header("Location: " . $result->checkout_url); + exit; +} diff --git a/examples/api/get_purchase.php b/examples/api/get_purchase.php index 10baf67..e9c22d9 100644 --- a/examples/api/get_purchase.php +++ b/examples/api/get_purchase.php @@ -6,8 +6,9 @@ $chip = new \Chip\ChipApi($config['brand_id'], $config['api_key'], $config['endpoint']); -$purchase_id = ''; # ID of the purchase: $purchase->id; +$purchaseId = ''; # ID of the purchase: $purchase->id; -$purchase = $chip->getPurchase($purchase_id); +$purchase = $chip->getPurchase($purchaseId); -print json_encode($purchase); \ No newline at end of file +header('Content-Type: application/json'); +echo json_encode($purchase, JSON_PRETTY_PRINT); diff --git a/examples/api/payment_methods.php b/examples/api/payment_methods.php index 48bad2e..0420e5c 100644 --- a/examples/api/payment_methods.php +++ b/examples/api/payment_methods.php @@ -8,4 +8,4 @@ $methods = $chip->getPaymentMethods(); -echo "
" . json_encode($methods, JSON_PRETTY_PRINT) . "
"; \ No newline at end of file +echo "
" . json_encode($methods, JSON_PRETTY_PRINT) . "
"; diff --git a/examples/api/public_key.php b/examples/api/public_key.php index 6a44ca6..d11cca5 100644 --- a/examples/api/public_key.php +++ b/examples/api/public_key.php @@ -6,18 +6,8 @@ $chip = new \Chip\ChipApi($config['brand_id'], $config['api_key'], $config['endpoint']); +# Get public key via SDK +$publicKey = $chip->getPublicKey(); -# GET PUBLIC KEY -$url = $config['endpoint'] . "public_key/"; -$curl = curl_init($url); -curl_setopt($curl, CURLOPT_URL, $url); -curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); -$headers = array( - "Authorization: Bearer " . $config['api_key'], -); -curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); -$publicKey = json_decode(curl_exec($curl)); -curl_close($curl); - -header("Content-Type: application/json"); -echo $publicKey; +header('Content-Type: application/json'); +echo json_encode(['public_key' => $publicKey]); diff --git a/examples/api/purchase_builder.php b/examples/api/purchase_builder.php new file mode 100644 index 0000000..83126df --- /dev/null +++ b/examples/api/purchase_builder.php @@ -0,0 +1,26 @@ +withBrandId($config['brand_id']) + ->withProduct('Test Product', 100, 1) + ->withClient('test@example.com', 'John Doe') + ->withSuccessRedirect($config['basedUrl'] . '/api/redirect.php?success=1') + ->withFailureRedirect($config['basedUrl'] . '/api/redirect.php?success=0') + ->withSuccessCallback($config['basedUrl'] . '/api/callback.php') + ->build(); + +$result = $chip->createPurchase($purchase); + +echo "

Purchase Created via Builder

"; +echo "
" . json_encode($result, JSON_PRETTY_PRINT) . "
"; + +if ($result && $result->checkout_url) { + echo "

Go to Checkout

"; +} diff --git a/examples/api/statements.php b/examples/api/statements.php new file mode 100644 index 0000000..435b2b0 --- /dev/null +++ b/examples/api/statements.php @@ -0,0 +1,20 @@ +listStatements(); +echo "

Statements

"; +echo "
" . json_encode($statements, JSON_PRETTY_PRINT) . "
"; + +# Schedule a new statement +$statement = new \Chip\Model\CompanyStatement(); +$statement->format = 'json'; + +$scheduled = $chip->scheduleStatement($statement); +echo "

Scheduled Statement

"; +echo "
" . json_encode($scheduled, JSON_PRETTY_PRINT) . "
"; diff --git a/examples/api/webhook.php b/examples/api/webhook.php index cec0858..57c1a3c 100644 --- a/examples/api/webhook.php +++ b/examples/api/webhook.php @@ -1,21 +1,21 @@ getPublicKey(); - # GET PUBLIC KEY - $publicKey = $config['webhook_public_key']; +$verify = \Chip\ChipApi::verify($post, $xSignature, $publicKey); - $verify = \Chip\ChipApi::verify($post, $xSignature, $publicKey); - error_log("/webhook EVENT: " . $data->event_type); - error_log("/webhook VERIFIED: " . ($verify ? "true" : "false")); +$data = json_decode($post); +error_log('/webhook EVENT: ' . $data->event_type); +error_log('/webhook VERIFIED: ' . ($verify ? 'true' : 'false')); diff --git a/examples/api/webhook_management.php b/examples/api/webhook_management.php new file mode 100644 index 0000000..79cc8ef --- /dev/null +++ b/examples/api/webhook_management.php @@ -0,0 +1,28 @@ +listWebhooks(); +echo "

Webhooks

"; +echo "
" . json_encode($webhooks, JSON_PRETTY_PRINT) . "
"; + +# Create a webhook +$webhook = new \Chip\Model\Webhook(); +$webhook->url = $config['basedUrl'] . '/api/webhook.php'; +$webhook->event_type = 'purchase.paid'; + +$created = $chip->createWebhook($webhook); +echo "

Created Webhook

"; +echo "
" . json_encode($created, JSON_PRETTY_PRINT) . "
"; + +# Update webhook +$webhookId = $created->id; +$webhook->url = $config['basedUrl'] . '/api/callback.php'; +$updated = $chip->updateWebhook($webhookId, $webhook); +echo "

Updated Webhook

"; +echo "
" . json_encode($updated, JSON_PRETTY_PRINT) . "
"; diff --git a/examples/composer.json b/examples/composer.json index 1017156..bdf72d4 100644 --- a/examples/composer.json +++ b/examples/composer.json @@ -1,15 +1,15 @@ { - "name": "chip/php-sdk-examples", - "type": "library", - "require": { - "chip/chip-sdk-php": "^1.0.0", - "php": ">=7.2.0", - }, - "license": "MIT", - "repositories": [ - { - "type": "vcs", - "url": "git@github.com:CHIPAsia/chip-php-sdk.git" - } - ] + "name": "chip/php-sdk-examples", + "type": "library", + "require": { + "chip/chip-sdk-php": "^2.0.0", + "php": ">=8.1.0" + }, + "license": "MIT", + "repositories": [ + { + "type": "vcs", + "url": "git@github.com:CHIPAsia/chip-php-sdk.git" + } + ] } diff --git a/examples/config.php b/examples/config.php index 5ba4fcd..81d28e5 100644 --- a/examples/config.php +++ b/examples/config.php @@ -1,9 +1,9 @@ '<>', - 'api_key' => '<>', - 'endpoint' => 'https://gate.chip-in.asia/api/v1/', - 'basedUrl' => '<>', - 'webhook_public_key' => "< '<>', + 'api_key' => '<>', + 'endpoint' => 'https://gate.chip-in.asia/api/v1/', + 'basedUrl' => '<>', + 'webhook_public_key' => "<>", // SHOULD BE WRAPPED IN DOUBLE QUOTES (") ]; diff --git a/examples/index.php b/examples/index.php index d68e24d..a20fddb 100644 --- a/examples/index.php +++ b/examples/index.php @@ -1,18 +1,36 @@ - -

Demo Merchant

+ + +

CHIP PHP SDK Examples

- Sample Actions: + Purchase Flow: - - \ No newline at end of file + + Account: + + + Clients: + + + Statements: + + + Webhooks: + + + diff --git a/lib/Builder/PurchaseBuilder.php b/lib/Builder/PurchaseBuilder.php new file mode 100644 index 0000000..4b35fdd --- /dev/null +++ b/lib/Builder/PurchaseBuilder.php @@ -0,0 +1,121 @@ +purchase = new Purchase(); + $this->purchase->purchase = new PurchaseDetails(); + } + + public static function create(): self + { + return new self(); + } + + public function brandId(string $brandId): self + { + $this->purchase->brand_id = $brandId; + + return $this; + } + + public function currency(string $currency): self + { + $this->purchase->purchase->currency = $currency; + + return $this; + } + + public function language(string $language): self + { + $this->purchase->purchase->language = $language; + + return $this; + } + + public function successRedirect(string $url): self + { + $this->purchase->success_redirect = $url; + + return $this; + } + + public function failureRedirect(string $url): self + { + $this->purchase->failure_redirect = $url; + + return $this; + } + + public function successCallback(string $url): self + { + $this->purchase->success_callback = $url; + + return $this; + } + + public function cancelRedirect(string $url): self + { + $this->purchase->cancel_redirect = $url; + + return $this; + } + + public function clientEmail(string $email): self + { + $this->ensureClient(); + $this->purchase->client->email = $email; + + return $this; + } + + public function clientPhone(string $phone): self + { + $this->ensureClient(); + $this->purchase->client->phone = $phone; + + return $this; + } + + public function clientFullName(string $fullName): self + { + $this->ensureClient(); + $this->purchase->client->full_name = $fullName; + + return $this; + } + + public function addProduct(string $name, int $price, float|string $quantity = 1.0): self + { + $product = new Product(); + $product->name = $name; + $product->price = $price; + $product->quantity = is_string($quantity) ? $quantity : (string) $quantity; + + $this->purchase->purchase->products[] = $product; + + return $this; + } + + public function build(): Purchase + { + return $this->purchase; + } + + private function ensureClient(): void + { + if ($this->purchase->client === null) { + $this->purchase->client = new ClientDetails(); + } + } +} diff --git a/lib/ChipApi.php b/lib/ChipApi.php index 4160c68..f70d394 100644 --- a/lib/ChipApi.php +++ b/lib/ChipApi.php @@ -1,65 +1,85 @@ $config + */ + public function __construct( + string $brandId, + string $apiKey, + string $base = 'https://gate.chip-in.asia/api/v1/', + array $config = [], + ?LoggerInterface $logger = null, + ) { + $httpClient = $this->createHttpClient($apiKey, $base, $config, $logger); + + $this->purchases = new PurchasesResource($httpClient); + $this->clients = new ClientsResource($httpClient); + $this->webhooks = new WebhooksResource($httpClient); + $this->paymentMethods = new PaymentMethodsResource($httpClient, $brandId); + $this->publicKey = new PublicKeyResource($httpClient); + $this->account = new AccountResource($httpClient); + $this->statements = new StatementsResource($httpClient); + $this->billing = new BillingResource($httpClient); + } + + /** + * @param array $config + */ + private function createHttpClient(string $apiKey, string $base, array $config, ?LoggerInterface $logger): ClientInterface + { + $guzzleClient = new GuzzleClient($apiKey, $base, $config, $logger); + + return new RetryClient($guzzleClient, 3, 1.0, $logger); + } - use Purchase, PaymentMethod, Client, Webhook, Billing; - - protected $client; - - protected $mapper; - - public function __construct( - protected string $brandId, - protected string $apiKey, - protected string $base = 'https://gate.chip-in.asia/api/v1/', - array $config = [] - ) { - $this->mapper = new \JsonMapper(); - $this->mapper->bStrictNullTypes = false; - $this->mapper->bEnforceMapType = false; - - $this->client = new \GuzzleHttp\Client(array_merge([ - 'base_uri' => $this->base, - ], $config)); - } - - protected function request(string $method, string $endpoint, array $options = array()) - { - $headers = []; - if ($this->apiKey) { - $headers['Authorization'] = 'Bearer ' . $this->apiKey; - } - $response = $this->client->request($method, $endpoint, array_merge(array( - 'headers' => $headers - ), $options)); - $body = (string)$response->getBody()->getContents(); - - return json_decode($body); - } - - /** - * - * @param string $content - * @param string $signature - * @param string $publicKey - * @return bool - */ - public static function verify(string $content, string $signature, string $publicKey): bool - { - return 1 === openssl_verify( - $content, - base64_decode($signature), - $publicKey, - 'sha256WithRSAEncryption' - ); - } + /** + * @param string $content + * @param string $signature + * @param string $publicKey + * @return bool + */ + public static function verify(string $content, string $signature, string $publicKey): bool + { + return 1 === openssl_verify( + $content, + base64_decode($signature), + $publicKey, + 'sha256WithRSAEncryption' + ); + } } diff --git a/lib/Exception/AuthenticationException.php b/lib/Exception/AuthenticationException.php new file mode 100644 index 0000000..2eef631 --- /dev/null +++ b/lib/Exception/AuthenticationException.php @@ -0,0 +1,7 @@ +|null */ + protected ?array $responseBody; + + /** + * @param array|null $responseBody + */ + public function __construct(string $message = '', int $code = 0, ?array $responseBody = null, ?Exception $previous = null) + { + parent::__construct($message, $code, $previous); + $this->responseBody = $responseBody; + } + + /** @return array|null */ + public function getResponseBody(): ?array + { + return $this->responseBody; + } +} diff --git a/lib/Exception/ClientException.php b/lib/Exception/ClientException.php new file mode 100644 index 0000000..8330d0d --- /dev/null +++ b/lib/Exception/ClientException.php @@ -0,0 +1,7 @@ + */ + protected array $errors = []; + + /** + * @param array|null $responseBody + */ + public function __construct(string $message = '', int $code = 0, ?array $responseBody = null, ?\Exception $previous = null) + { + parent::__construct($message, $code, $responseBody, $previous); + + if ($responseBody !== null && isset($responseBody['errors']) && is_array($responseBody['errors'])) { + $this->errors = $responseBody['errors']; + } + } + + /** @return array */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/lib/Http/ClientInterface.php b/lib/Http/ClientInterface.php new file mode 100644 index 0000000..180be9f --- /dev/null +++ b/lib/Http/ClientInterface.php @@ -0,0 +1,15 @@ + $options + * @return \stdClass|array + * @throws \Chip\Exception\ChipApiException + */ + public function request(string $method, string $endpoint, array $options = []): \stdClass|array; +} diff --git a/lib/Http/GuzzleClient.php b/lib/Http/GuzzleClient.php new file mode 100644 index 0000000..c2db5d6 --- /dev/null +++ b/lib/Http/GuzzleClient.php @@ -0,0 +1,98 @@ + $config + */ + public function __construct( + private readonly string $apiKey, + string $baseUri = 'https://gate.chip-in.asia/api/v1/', + array $config = [], + ?LoggerInterface $logger = null, + ) { + $this->logger = $logger ?? new NullLogger(); + + $mergedConfig = array_merge([ + 'base_uri' => $baseUri, + 'timeout' => $config['timeout'] ?? 30, + ], $config); + + $this->client = new \GuzzleHttp\Client($mergedConfig); + } + + /** + * @param array $options + * @return \stdClass|array + */ + public function request(string $method, string $endpoint, array $options = []): \stdClass|array + { + $headers = []; + if ($this->apiKey) { + $headers['Authorization'] = 'Bearer ' . $this->apiKey; + } + + $mergedOptions = array_merge([ + 'headers' => $headers, + ], $options); + + $this->logger->debug('CHIP API request', [ + 'method' => $method, + 'endpoint' => $endpoint, + ]); + + try { + $response = $this->client->request($method, $endpoint, $mergedOptions); + } catch (GuzzleClientException $e) { + $response = $e->getResponse(); + $statusCode = $response->getStatusCode(); + $body = json_decode((string) $response->getBody(), true) ?? []; + $message = $body['detail'] ?? $body['message'] ?? $e->getMessage(); + + $this->logger->error('CHIP API client error', [ + 'status' => $statusCode, + 'message' => $message, + ]); + + throw match ($statusCode) { + 401 => new AuthenticationException($message, $statusCode, $body, $e), + 404 => new NotFoundException($message, $statusCode, $body, $e), + 422 => new ValidationException($message, $statusCode, $body, $e), + default => new ClientException($message, $statusCode, $body, $e), + }; + } catch (GuzzleServerException $e) { + $response = $e->getResponse(); + $statusCode = $response->getStatusCode(); + $body = json_decode((string) $response->getBody(), true) ?? []; + $message = $body['detail'] ?? $body['message'] ?? $e->getMessage(); + + $this->logger->error('CHIP API server error', [ + 'status' => $statusCode, + 'message' => $message, + ]); + + throw new ServerException($message, $statusCode, $body, $e); + } + + $body = (string) $response->getBody()->getContents(); + + return json_decode($body, true) ?? new \stdClass(); + } +} diff --git a/lib/Http/RetryClient.php b/lib/Http/RetryClient.php new file mode 100644 index 0000000..5b17440 --- /dev/null +++ b/lib/Http/RetryClient.php @@ -0,0 +1,79 @@ +logger = $logger ?? new NullLogger(); + } + + /** + * @param array $options + * @return \stdClass|array + */ + public function request(string $method, string $endpoint, array $options = []): \stdClass|array + { + $attempt = 0; + + while (true) { + try { + return $this->client->request($method, $endpoint, $options); + } catch (ChipApiException $e) { + $statusCode = $e->getCode(); + + if (! $this->shouldRetry($statusCode, $attempt)) { + throw $e; + } + + $delay = $this->calculateDelay($e, $attempt); + $attempt++; + + $this->logger->warning('CHIP API retry', [ + 'attempt' => $attempt, + 'delay' => $delay, + 'status' => $statusCode, + 'endpoint' => $endpoint, + ]); + + usleep((int) ($delay * 1_000_000)); + } + } + } + + private function shouldRetry(int $statusCode, int $attempt): bool + { + if ($attempt >= $this->maxRetries) { + return false; + } + + return $statusCode >= 500 || $statusCode === 429; + } + + private function calculateDelay(ChipApiException $e, int $attempt): float + { + if ($e->getCode() === 429) { + $body = $e->getResponseBody() ?? []; + $retryAfter = $body['retry_after'] ?? null; + + if (is_numeric($retryAfter)) { + return (float) $retryAfter; + } + } + + return $this->baseDelay * (2 ** $attempt); + } +} diff --git a/lib/Model/BankAccount.php b/lib/Model/BankAccount.php index 06cbaf2..3e20854 100644 --- a/lib/Model/BankAccount.php +++ b/lib/Model/BankAccount.php @@ -1,21 +1,36 @@ $data + */ + public static function fromArray(array $data): self + { + $account = new self(); + $account->bank_account = $data['bank_account'] ?? ''; + $account->bank_code = $data['bank_code'] ?? ''; + + return $account; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/Billing/BillingTemplate.php b/lib/Model/Billing/BillingTemplate.php index b5bcde6..40bca58 100644 --- a/lib/Model/Billing/BillingTemplate.php +++ b/lib/Model/Billing/BillingTemplate.php @@ -4,50 +4,136 @@ class BillingTemplate implements \JsonSerializable { - public $type; - public $id; - public $created_on; - public $updated_on; - - public $clients; //required - public $purchase; //required - public $company_id; - public $number_of_billing_cycles; - public $is_test; - public $user_id; - public $brand_id; //required - public $title; //required - public $is_subscription; //required - - //invoice_* required if `is_subscription` is false - public $invoice_issued; - public $invoice_due; - public $invoice_skip_capture; - public $invoice_send_receipt; - - //subscription_* required if `is_subscription` is true - public $subscription_period; - public $subscription_period_units; - public $subscription_due_period; - public $subscription_due_period_units; - public $subscription_charge_period_end; - public $subscription_trial_periods; - public $subscription_active; - public $subscription_has_active_clients; - - public $force_recurring; - - #[\ReturnTypeWillChange] - public function jsonSerialize() - { - return array_filter((array) $this, array($this, 'allow_non_null')); - } - - private function allow_non_null($var) - { - if (is_null($var)) { - return false; + /** @var string|null */ + public $type; + + /** @var string|null */ + public $id; + + /** @var int|null */ + public $created_on; + + /** @var int|null */ + public $updated_on; + + /** @var mixed|null */ + public $clients; + + /** @var mixed|null */ + public $purchase; + + /** @var string|null */ + public $company_id; + + /** @var int|null */ + public $number_of_billing_cycles; + + /** @var bool|null */ + public $is_test; + + /** @var string|null */ + public $user_id; + + /** @var string|null */ + public $brand_id; + + /** @var string|null */ + public $title; + + /** @var bool|null */ + public $is_subscription; + + /** @var int|null */ + public $invoice_issued; + + /** @var int|null */ + public $invoice_due; + + /** @var bool|null */ + public $invoice_skip_capture; + + /** @var bool|null */ + public $invoice_send_receipt; + + /** @var int|null */ + public $subscription_period; + + /** @var string|null */ + public $subscription_period_units; + + /** @var int|null */ + public $subscription_due_period; + + /** @var string|null */ + public $subscription_due_period_units; + + /** @var int|null */ + public $subscription_charge_period_end; + + /** @var int|null */ + public $subscription_trial_periods; + + /** @var bool|null */ + public $subscription_active; + + /** @var bool|null */ + public $subscription_has_active_clients; + + /** @var bool|null */ + public $force_recurring; + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $template = new self(); + $template->type = $data['type'] ?? null; + $template->id = $data['id'] ?? null; + $template->created_on = $data['created_on'] ?? null; + $template->updated_on = $data['updated_on'] ?? null; + $template->clients = $data['clients'] ?? null; + $template->purchase = $data['purchase'] ?? null; + $template->company_id = $data['company_id'] ?? null; + $template->number_of_billing_cycles = $data['number_of_billing_cycles'] ?? null; + $template->is_test = $data['is_test'] ?? null; + $template->user_id = $data['user_id'] ?? null; + $template->brand_id = $data['brand_id'] ?? null; + $template->title = $data['title'] ?? null; + $template->is_subscription = $data['is_subscription'] ?? null; + $template->invoice_issued = $data['invoice_issued'] ?? null; + $template->invoice_due = $data['invoice_due'] ?? null; + $template->invoice_skip_capture = $data['invoice_skip_capture'] ?? null; + $template->invoice_send_receipt = $data['invoice_send_receipt'] ?? null; + $template->subscription_period = $data['subscription_period'] ?? null; + $template->subscription_period_units = $data['subscription_period_units'] ?? null; + $template->subscription_due_period = $data['subscription_due_period'] ?? null; + $template->subscription_due_period_units = $data['subscription_due_period_units'] ?? null; + $template->subscription_charge_period_end = $data['subscription_charge_period_end'] ?? null; + $template->subscription_trial_periods = $data['subscription_trial_periods'] ?? null; + $template->subscription_active = $data['subscription_active'] ?? null; + $template->subscription_has_active_clients = $data['subscription_has_active_clients'] ?? null; + $template->force_recurring = $data['force_recurring'] ?? null; + + return $template; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this, [$this, 'allow_non_null']); + } + + /** + * @param mixed $var + * @return bool + */ + private function allow_non_null($var) + { + if (is_null($var)) { + return false; + } + + return true; } - return true; - } } diff --git a/lib/Model/Billing/BillingTemplateClient.php b/lib/Model/Billing/BillingTemplateClient.php index c45bd4f..301aa9d 100644 --- a/lib/Model/Billing/BillingTemplateClient.php +++ b/lib/Model/Billing/BillingTemplateClient.php @@ -4,23 +4,67 @@ class BillingTemplateClient implements \JsonSerializable { - public $type; - public $id; - public $created_on; - public $updated_on; - - public $client_id; //required - public $number_of_billing_cycles_passed; - public $status; - public $subscription_billing_scheduled_on; - public $payment_method_whitelist; - public $send_invoice_on_charge_failure; - public $send_invoice_on_add_subscriber; - public $send_receipt; - - #[\ReturnTypeWillChange] - public function jsonSerialize() - { - return array_filter((array) $this); - } + /** @var string|null */ + public $type; + + /** @var string|null */ + public $id; + + /** @var int|null */ + public $created_on; + + /** @var int|null */ + public $updated_on; + + /** @var string|null */ + public $client_id; + + /** @var int|null */ + public $number_of_billing_cycles_passed; + + /** @var string|null */ + public $status; + + /** @var int|null */ + public $subscription_billing_scheduled_on; + + /** @var string[]|null */ + public $payment_method_whitelist; + + /** @var bool|null */ + public $send_invoice_on_charge_failure; + + /** @var bool|null */ + public $send_invoice_on_add_subscriber; + + /** @var bool|null */ + public $send_receipt; + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $client = new self(); + $client->type = $data['type'] ?? null; + $client->id = $data['id'] ?? null; + $client->created_on = $data['created_on'] ?? null; + $client->updated_on = $data['updated_on'] ?? null; + $client->client_id = $data['client_id'] ?? null; + $client->number_of_billing_cycles_passed = $data['number_of_billing_cycles_passed'] ?? null; + $client->status = $data['status'] ?? null; + $client->subscription_billing_scheduled_on = $data['subscription_billing_scheduled_on'] ?? null; + $client->payment_method_whitelist = $data['payment_method_whitelist'] ?? null; + $client->send_invoice_on_charge_failure = $data['send_invoice_on_charge_failure'] ?? null; + $client->send_invoice_on_add_subscriber = $data['send_invoice_on_add_subscriber'] ?? null; + $client->send_receipt = $data['send_receipt'] ?? null; + + return $client; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/Billing/BillingTemplateClientAddSubscriber.php b/lib/Model/Billing/BillingTemplateClientAddSubscriber.php index 5db7fdd..46d731f 100644 --- a/lib/Model/Billing/BillingTemplateClientAddSubscriber.php +++ b/lib/Model/Billing/BillingTemplateClientAddSubscriber.php @@ -2,24 +2,35 @@ namespace Chip\Model\Billing; +use Chip\Model\Purchase; + class BillingTemplateClientAddSubscriber implements \JsonSerializable { + /** @var BillingTemplateClient|null */ + public $billing_template_client; + + /** @var Purchase|null */ + public $purchase; - /** - * - * @var \Chip\Model\Billing\BillingTemplateClient - */ - public $billing_template_client; + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $result = new self(); + $result->billing_template_client = isset($data['billing_template_client']) && is_array($data['billing_template_client']) + ? BillingTemplateClient::fromArray($data['billing_template_client']) + : null; + $result->purchase = isset($data['purchase']) && is_array($data['purchase']) + ? Purchase::fromArray($data['purchase']) + : null; - /** - * - * @var \Chip\Model\Purchase - */ - public $purchase; + return $result; + } - #[\ReturnTypeWillChange] - public function jsonSerialize() - { - return array_filter((array) $this); - } + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/Billing/BillingTemplateClientList.php b/lib/Model/Billing/BillingTemplateClientList.php index 4c50b00..8da146f 100644 --- a/lib/Model/Billing/BillingTemplateClientList.php +++ b/lib/Model/Billing/BillingTemplateClientList.php @@ -4,19 +4,33 @@ class BillingTemplateClientList implements \JsonSerializable { + /** @var BillingTemplateClient[]|null */ + public $results; - /** - * - * @var BillingTemplateClient[] - */ - public $results; + /** @var string|null */ + public $next; - public $next; - public $previous; + /** @var string|null */ + public $previous; - #[\ReturnTypeWillChange] - public function jsonSerialize() - { - return array_filter((array) $this); - } + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $list = new self(); + $list->results = isset($data['results']) && is_array($data['results']) + ? array_map(fn (array $r) => BillingTemplateClient::fromArray($r), $data['results']) + : null; + $list->next = $data['next'] ?? null; + $list->previous = $data['previous'] ?? null; + + return $list; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/Billing/BillingTemplateList.php b/lib/Model/Billing/BillingTemplateList.php index 775223e..4369fc1 100644 --- a/lib/Model/Billing/BillingTemplateList.php +++ b/lib/Model/Billing/BillingTemplateList.php @@ -4,19 +4,33 @@ class BillingTemplateList implements \JsonSerializable { + /** @var BillingTemplate[]|null */ + public $results; - /** - * - * @var BillingTemplate[] - */ - public $results; + /** @var string|null */ + public $next; - public $next; - public $previous; + /** @var string|null */ + public $previous; - #[\ReturnTypeWillChange] - public function jsonSerialize() - { - return array_filter((array) $this); - } + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $list = new self(); + $list->results = isset($data['results']) && is_array($data['results']) + ? array_map(fn (array $r) => BillingTemplate::fromArray($r), $data['results']) + : null; + $list->next = $data['next'] ?? null; + $list->previous = $data['previous'] ?? null; + + return $list; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/ClientDetails.php b/lib/Model/ClientDetails.php index 17c88bc..f229bc5 100644 --- a/lib/Model/ClientDetails.php +++ b/lib/Model/ClientDetails.php @@ -1,124 +1,183 @@ $data + */ + public static function fromArray(array $data): self + { + $client = new self(); + $client->id = $data['id'] ?? ''; + $client->email = $data['email'] ?? ''; + $client->phone = $data['phone'] ?? ''; + $client->full_name = $data['full_name'] ?? ''; + $client->personal_code = $data['personal_code'] ?? ''; + $client->street_address = $data['street_address'] ?? ''; + $client->country = $data['country'] ?? ''; + $client->city = $data['city'] ?? ''; + $client->zip_code = $data['zip_code'] ?? ''; + $client->shipping_street_address = $data['shipping_street_address'] ?? ''; + $client->shipping_country = $data['shipping_country'] ?? ''; + $client->shipping_city = $data['shipping_city'] ?? ''; + $client->shipping_zip_code = $data['shipping_zip_code'] ?? ''; + $client->cc = $data['cc'] ?? []; + $client->bcc = $data['bcc'] ?? []; + $client->legal_name = $data['legal_name'] ?? ''; + $client->brand_name = $data['brand_name'] ?? ''; + $client->registration_number = $data['registration_number'] ?? ''; + $client->tax_number = $data['tax_number'] ?? ''; + $client->state = $data['state'] ?? null; + $client->shipping_state = $data['shipping_state'] ?? null; + $client->bank_account = $data['bank_account'] ?? null; + $client->bank_code = $data['bank_code'] ?? null; + + return $client; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/ClientList.php b/lib/Model/ClientList.php index d35a9b2..b7c5e98 100644 --- a/lib/Model/ClientList.php +++ b/lib/Model/ClientList.php @@ -4,19 +4,34 @@ class ClientList implements \JsonSerializable { + /** @var ClientDetails[]|null */ + public $results; - /** - * - * @var ClientDetails[] - */ - public $results; + /** @var string|null */ + public $next; - public $next; - public $previous; + /** @var string|null */ + public $previous; - #[\ReturnTypeWillChange] - public function jsonSerialize() - { - return array_filter((array) $this); - } + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $list = new self(); + $list->results = array_map( + fn (array $c) => ClientDetails::fromArray($c), + $data['results'] ?? [] + ); + $list->next = $data['next'] ?? null; + $list->previous = $data['previous'] ?? null; + + return $list; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/ClientRecurringToken.php b/lib/Model/ClientRecurringToken.php new file mode 100644 index 0000000..4998b48 --- /dev/null +++ b/lib/Model/ClientRecurringToken.php @@ -0,0 +1,46 @@ + $data + */ + public static function fromArray(array $data): self + { + $token = new self(); + $token->type = $data['type'] ?? null; + $token->id = $data['id'] ?? null; + $token->created_on = $data['created_on'] ?? null; + $token->updated_on = $data['updated_on'] ?? null; + $token->payment_method = $data['payment_method'] ?? null; + $token->description = $data['description'] ?? null; + + return $token; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } +} diff --git a/lib/Model/ClientRecurringTokenList.php b/lib/Model/ClientRecurringTokenList.php new file mode 100644 index 0000000..d79d784 --- /dev/null +++ b/lib/Model/ClientRecurringTokenList.php @@ -0,0 +1,37 @@ + $data + */ + public static function fromArray(array $data): self + { + $list = new self(); + $list->results = array_map( + fn (array $t) => ClientRecurringToken::fromArray($t), + $data['results'] ?? [] + ); + $list->next = $data['next'] ?? null; + $list->previous = $data['previous'] ?? null; + + return $list; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } +} diff --git a/lib/Model/CompanyStatement.php b/lib/Model/CompanyStatement.php new file mode 100644 index 0000000..51d75a5 --- /dev/null +++ b/lib/Model/CompanyStatement.php @@ -0,0 +1,74 @@ + $data + */ + public static function fromArray(array $data): self + { + $statement = new self(); + $statement->type = $data['type'] ?? null; + $statement->id = $data['id'] ?? null; + $statement->created_on = $data['created_on'] ?? null; + $statement->updated_on = $data['updated_on'] ?? null; + $statement->format = $data['format'] ?? null; + $statement->timezone = $data['timezone'] ?? null; + $statement->is_test = $data['is_test'] ?? null; + $statement->company_uid = $data['company_uid'] ?? null; + $statement->query_string = $data['query_string'] ?? null; + $statement->status = $data['status'] ?? null; + $statement->download_url = $data['download_url'] ?? null; + $statement->began_on = $data['began_on'] ?? null; + $statement->finished_on = $data['finished_on'] ?? null; + + return $statement; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } +} diff --git a/lib/Model/CompanyStatementList.php b/lib/Model/CompanyStatementList.php new file mode 100644 index 0000000..30dc2a1 --- /dev/null +++ b/lib/Model/CompanyStatementList.php @@ -0,0 +1,37 @@ + $data + */ + public static function fromArray(array $data): self + { + $list = new self(); + $list->results = array_map( + fn (array $s) => CompanyStatement::fromArray($s), + $data['results'] ?? [] + ); + $list->next = $data['next'] ?? null; + $list->previous = $data['previous'] ?? null; + + return $list; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } +} diff --git a/lib/Model/IssuerDetails.php b/lib/Model/IssuerDetails.php index bf0ff34..254a190 100644 --- a/lib/Model/IssuerDetails.php +++ b/lib/Model/IssuerDetails.php @@ -1,69 +1,95 @@ $data + */ + public static function fromArray(array $data): self + { + $details = new self(); + $details->website = $data['website'] ?? ''; + $details->legal_street_address = $data['legal_street_address'] ?? ''; + $details->legal_country = $data['legal_country'] ?? ''; + $details->legal_city = $data['legal_city'] ?? ''; + $details->legal_zip_code = $data['legal_zip_code'] ?? ''; + $details->bank_accounts = array_map( + fn (array $a) => BankAccount::fromArray($a), + $data['bank_accounts'] ?? [] + ); + $details->legal_name = $data['legal_name'] ?? ''; + $details->brand_name = $data['brand_name'] ?? ''; + $details->registration_number = $data['registration_number'] ?? ''; + $details->tax_number = $data['tax_number'] ?? ''; + + return $details; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/PaymentDetails.php b/lib/Model/PaymentDetails.php index 6414443..6ed79fa 100644 --- a/lib/Model/PaymentDetails.php +++ b/lib/Model/PaymentDetails.php @@ -1,75 +1,99 @@ $data + */ + public static function fromArray(array $data): self + { + $details = new self(); + $details->is_outgoing = $data['is_outgoing'] ?? false; + $details->payment_type = $data['payment_type'] ?? ''; + $details->amount = $data['amount'] ?? 0; + $details->currency = $data['currency'] ?? ''; + $details->net_amount = $data['net_amount'] ?? 0; + $details->fee_amount = $data['fee_amount'] ?? 0; + $details->pending_amount = $data['pending_amount'] ?? 0; + $details->pending_unfreeze_on = $data['pending_unfreeze_on'] ?? 0; + $details->description = $data['description'] ?? ''; + $details->paid_on = $data['paid_on'] ?? 0; + $details->remote_paid_on = $data['remote_paid_on'] ?? 0; + + return $details; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } diff --git a/lib/Model/PaymentMethods.php b/lib/Model/PaymentMethods.php index 4a69f76..93ee1f3 100644 --- a/lib/Model/PaymentMethods.php +++ b/lib/Model/PaymentMethods.php @@ -1,46 +1,68 @@ + */ + public $by_country; + + /** + * + * @var array + * @phpstan-var array + */ + public $country_names; + + /** + * + * @var array + * @phpstan-var array + */ + public $names; + /** + * + * @var string[] + */ + public $card_methods; + + /** + * + * @var array + * @phpstan-var array + */ + public $logos; + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $methods = new self(); + $methods->available_payment_methods = $data['available_payment_methods'] ?? []; + $methods->by_country = $data['by_country'] ?? []; + $methods->country_names = $data['country_names'] ?? []; + $methods->names = $data['names'] ?? []; + $methods->card_methods = $data['card_methods'] ?? []; + $methods->logos = $data['logos'] ?? []; + + return $methods; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } +} diff --git a/lib/Model/Product.php b/lib/Model/Product.php index 426d001..9e771e8 100644 --- a/lib/Model/Product.php +++ b/lib/Model/Product.php @@ -1,42 +1,77 @@ $data + */ + public static function fromArray(array $data): self + { + $product = new self(); + $product->name = $data['name'] ?? null; + $product->quantity = $data['quantity'] ?? null; + $product->price = $data['price'] ?? null; + $product->discount = $data['discount'] ?? null; + $product->tax_percent = $data['tax_percent'] ?? null; + $product->category = $data['category'] ?? null; + $product->total_price_override = $data['total_price_override'] ?? null; + + return $product; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this, [$this, 'allow_non_null']); + } + + /** + * @param mixed $var + * @return bool + */ + private function allow_non_null($var) + { + if (is_null($var)) { + return false; + } + + return true; + } +} diff --git a/lib/Model/Purchase.php b/lib/Model/Purchase.php index 939bf79..07d7f5a 100644 --- a/lib/Model/Purchase.php +++ b/lib/Model/Purchase.php @@ -1,246 +1,404 @@ + */ + public $status_history; + + /** + * + * @var int + */ + public $viewed_on; + + /** + * + * @var string + */ + public $company_id; + + /** + * + * @var bool + */ + public $is_test; + + /** + * + * @var string + */ + public $user_id; + + /** + * + * @var string + */ + public $brand_id; + + /** + * + * @var string + */ + public $billing_template_id; + + /** + * + * @var string + */ + public $client_id; + + /** + * + * @var bool + */ + public $send_receipt; + + /** + * + * @var bool + */ + public $is_recurring_token; + + /** + * + * @var string + */ + public $recurring_token; + + /** + * + * @var bool + */ + public $force_recurring; + + /** + * + * @var bool + */ + public $skip_capture; + + /** + * + * @var string + */ + public $reference_generated; + + /** + * + * @var string + */ + public $reference; + + /** + * + * @var string|null + */ + public $issued; + + /** + * + * @var int + */ + public $due; + + /** + * + * @var string + */ + public $refund_availability; + + /** + * + * @var int + */ + public $refundable_amount; + + /** + * + * @var object + */ + public $currency_conversion; + + /** + * + * @var string[] + */ + public $payment_method_whitelist; + + /** + * + * @var string + */ + public $success_redirect; + + /** + * + * @var string + */ + public $failure_redirect; + + /** + * + * @var string + */ + public $cancel_redirect; + + /** + * + * @var string + */ + public $success_callback; + + /** + * + * @var string + */ + public $creator_agent; + + /** + * + * @var string + */ + public $platform; + + /** + * + * @var string + */ + public $product; + + /** + * + * @var string + */ + public $created_from_ip; + + /** + * + * @var string + */ + public $invoice_url; + + /** + * + * @var string + */ + public $checkout_url; + + /** + * + * @var string + */ + public $direct_post_url; + + /** + * + * @var string|null + */ + public $notes; + + /** + * + * @var bool + */ + public $marked_as_paid; + + /** + * + * @var string|null + */ + public $order_id; + + /** + * + * @var array + * @phpstan-var array + */ + public $upsell_campaigns; + + /** + * + * @var string|null + */ + public $referral_campaign_id; + + /** + * + * @var string|null + */ + public $referral_code; + + /** + * + * @var object|null + */ + public $referral_code_details; + + /** + * + * @var string|null + */ + public $referral_code_generated; + + /** + * + * @var object|null + */ + public $retain_level_details; + + /** + * + * @var bool + */ + public $can_retrieve; + + /** + * + * @var bool + */ + public $can_chargeback; + + /** + * + * @var bool + */ + public $can_reverse_chargeback; + + /** + * + * @var string[] + */ + public $tags; + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $purchase = new self(); + $purchase->id = $data['id'] ?? ''; + + if (isset($data['client']) && is_array($data['client'])) { + $purchase->client = ClientDetails::fromArray($data['client']); + } + + if (isset($data['purchase']) && is_array($data['purchase'])) { + $purchase->purchase = PurchaseDetails::fromArray($data['purchase']); + } + + if (isset($data['payment']) && is_array($data['payment'])) { + $purchase->payment = PaymentDetails::fromArray($data['payment']); + } + + if (isset($data['issuer_details']) && is_array($data['issuer_details'])) { + $purchase->issuer_details = IssuerDetails::fromArray($data['issuer_details']); + } + + $purchase->transaction_data = isset($data['transaction_data']) ? (object) $data['transaction_data'] : new \stdClass(); + $purchase->status = $data['status'] ?? ''; + $purchase->status_history = array_map( + fn (array $h) => (object) $h, + $data['status_history'] ?? [] + ); + $purchase->viewed_on = $data['viewed_on'] ?? 0; + $purchase->company_id = $data['company_id'] ?? ''; + $purchase->is_test = $data['is_test'] ?? false; + $purchase->user_id = $data['user_id'] ?? ''; + $purchase->brand_id = $data['brand_id'] ?? ''; + $purchase->billing_template_id = $data['billing_template_id'] ?? ''; + $purchase->client_id = $data['client_id'] ?? ''; + $purchase->send_receipt = $data['send_receipt'] ?? false; + $purchase->is_recurring_token = $data['is_recurring_token'] ?? false; + $purchase->recurring_token = $data['recurring_token'] ?? ''; + $purchase->force_recurring = $data['force_recurring'] ?? false; + $purchase->skip_capture = $data['skip_capture'] ?? false; + $purchase->reference_generated = $data['reference_generated'] ?? ''; + $purchase->reference = $data['reference'] ?? ''; + $purchase->issued = $data['issued'] ?? null; + $purchase->due = $data['due'] ?? 0; + $purchase->refund_availability = $data['refund_availability'] ?? ''; + $purchase->refundable_amount = $data['refundable_amount'] ?? 0; + $purchase->currency_conversion = isset($data['currency_conversion']) ? (object) $data['currency_conversion'] : new \stdClass(); + $purchase->payment_method_whitelist = $data['payment_method_whitelist'] ?? []; + $purchase->success_redirect = $data['success_redirect'] ?? ''; + $purchase->failure_redirect = $data['failure_redirect'] ?? ''; + $purchase->cancel_redirect = $data['cancel_redirect'] ?? ''; + $purchase->success_callback = $data['success_callback'] ?? ''; + $purchase->creator_agent = $data['creator_agent'] ?? ''; + $purchase->platform = $data['platform'] ?? ''; + $purchase->product = $data['product'] ?? ''; + $purchase->created_from_ip = $data['created_from_ip'] ?? ''; + $purchase->invoice_url = $data['invoice_url'] ?? ''; + $purchase->checkout_url = $data['checkout_url'] ?? ''; + $purchase->direct_post_url = $data['direct_post_url'] ?? ''; + $purchase->notes = $data['notes'] ?? null; + $purchase->marked_as_paid = $data['marked_as_paid'] ?? false; + $purchase->order_id = $data['order_id'] ?? null; + $purchase->upsell_campaigns = $data['upsell_campaigns'] ?? []; + $purchase->referral_campaign_id = $data['referral_campaign_id'] ?? null; + $purchase->referral_code = $data['referral_code'] ?? null; + $purchase->referral_code_details = isset($data['referral_code_details']) ? (object) $data['referral_code_details'] : null; + $purchase->referral_code_generated = $data['referral_code_generated'] ?? null; + $purchase->retain_level_details = isset($data['retain_level_details']) ? (object) $data['retain_level_details'] : null; + $purchase->can_retrieve = $data['can_retrieve'] ?? false; + $purchase->can_chargeback = $data['can_chargeback'] ?? false; + $purchase->can_reverse_chargeback = $data['can_reverse_chargeback'] ?? false; + $purchase->tags = $data['tags'] ?? []; + + return $purchase; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } } - diff --git a/lib/Model/PurchaseDetails.php b/lib/Model/PurchaseDetails.php index d495dd6..1e624ba 100644 --- a/lib/Model/PurchaseDetails.php +++ b/lib/Model/PurchaseDetails.php @@ -1,95 +1,160 @@ + */ + public $shipping_options; + + /** + * + * @var object|null + */ + public $payment_method_details; + + /** + * + * @var bool + */ + public $has_upsell_products; + + /** + * + * @var bool + */ + public $single_attempt; + + /** + * + * @var object|null + */ + public $metadata; + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $details = new self(); + $details->currency = $data['currency'] ?? ''; + $details->products = array_map( + fn (array $p) => Product::fromArray($p), + $data['products'] ?? [] + ); + $details->total = $data['total'] ?? 0; + $details->language = $data['language'] ?? ''; + $details->notes = $data['notes'] ?? ''; + $details->debt = $data['debt'] ?? 0; + $details->subtotal_override = $data['subtotal_override'] ?? 0; + $details->total_tax_override = $data['total_tax_override'] ?? 0; + $details->total_discount_override = $data['total_discount_override'] ?? 0; + $details->total_override = $data['total_override'] ?? 0; + $details->request_client_details = $data['request_client_details'] ?? []; + $details->timezone = $data['timezone'] ?? ''; + $details->due_strict = $data['due_strict'] ?? false; + $details->email_message = $data['email_message'] ?? ''; + $details->shipping_options = $data['shipping_options'] ?? []; + $details->payment_method_details = isset($data['payment_method_details']) ? (object) $data['payment_method_details'] : null; + $details->has_upsell_products = $data['has_upsell_products'] ?? false; + $details->single_attempt = $data['single_attempt'] ?? false; + $details->metadata = isset($data['metadata']) ? (object) $data['metadata'] : null; + + return $details; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } +} diff --git a/lib/Model/Webhook.php b/lib/Model/Webhook.php index e516c11..313c70e 100644 --- a/lib/Model/Webhook.php +++ b/lib/Model/Webhook.php @@ -1,65 +1,85 @@ $data + */ + public static function fromArray(array $data): self + { + $webhook = new self(); + $webhook->type = $data['type'] ?? ''; + $webhook->id = $data['id'] ?? ''; + $webhook->created_on = $data['created_on'] ?? 0; + $webhook->updated_on = $data['updated_on'] ?? 0; + $webhook->title = $data['title'] ?? ''; + $webhook->all_events = $data['all_events'] ?? false; + $webhook->public_key = $data['public_key'] ?? ''; + $webhook->events = $data['events'] ?? []; + $webhook->callback = $data['callback'] ?? ''; + return $webhook; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } +} diff --git a/lib/Model/WebhookList.php b/lib/Model/WebhookList.php new file mode 100644 index 0000000..ffbe8ba --- /dev/null +++ b/lib/Model/WebhookList.php @@ -0,0 +1,37 @@ + $data + */ + public static function fromArray(array $data): self + { + $list = new self(); + $list->results = array_map( + fn (array $w) => Webhook::fromArray($w), + $data['results'] ?? [] + ); + $list->next = $data['next'] ?? null; + $list->previous = $data['previous'] ?? null; + + return $list; + } + + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return array_filter((array) $this); + } +} diff --git a/lib/Resource/AccountResource.php b/lib/Resource/AccountResource.php new file mode 100644 index 0000000..e7aa1d6 --- /dev/null +++ b/lib/Resource/AccountResource.php @@ -0,0 +1,44 @@ + $filters + * @return array + */ + public function balance(array $filters = []): array + { + $response = $this->client->request('GET', 'account/json/balance/', [ + 'query' => $filters, + ]); + + $json = json_encode($response); + + return json_decode($json !== false ? $json : '[]', true); + } + + /** + * @param array $filters + * @return array + */ + public function turnover(array $filters = []): array + { + $response = $this->client->request('GET', 'account/json/turnover/', [ + 'query' => $filters, + ]); + + $json = json_encode($response); + + return json_decode($json !== false ? $json : '[]', true); + } +} diff --git a/lib/Resource/BillingResource.php b/lib/Resource/BillingResource.php new file mode 100644 index 0000000..14e85b3 --- /dev/null +++ b/lib/Resource/BillingResource.php @@ -0,0 +1,146 @@ +|\stdClass + */ + public function create(BillingTemplate $billing): array|\stdClass + { + return $this->client->request('POST', 'billing/', [ + 'json' => $billing, + ]); + } + + public function createTemplate(BillingTemplate $billing): BillingTemplate + { + $response = $this->client->request('POST', 'billing_templates/', [ + 'json' => $billing, + ]); + + return BillingTemplate::fromArray((array) $response); + } + + public function listTemplates(): BillingTemplateList + { + $response = $this->client->request('GET', 'billing_templates/'); + + return BillingTemplateList::fromArray((array) $response); + } + + /** + * @return \Generator + */ + public function iterateTemplates(): \Generator + { + $url = 'billing_templates/'; + + while ($url !== null) { + $response = $this->client->request('GET', $url); + $list = BillingTemplateList::fromArray((array) $response); + + foreach ($list->results ?? [] as $template) { + yield $template; + } + + $url = $list->next; + } + } + + public function getTemplate(string $billingId): BillingTemplate + { + $response = $this->client->request('GET', "billing_templates/$billingId/"); + + return BillingTemplate::fromArray((array) $response); + } + + public function updateTemplate(string $billingId, BillingTemplate $billing): BillingTemplate + { + $response = $this->client->request('PUT', "billing_templates/$billingId/", [ + 'json' => $billing, + ]); + + return BillingTemplate::fromArray((array) $response); + } + + public function deleteTemplate(string $billingId): void + { + $this->client->request('DELETE', "billing_templates/$billingId/"); + } + + public function sendInvoice(string $billingId, BillingTemplateClient $client): Purchase + { + $response = $this->client->request('POST', "billing_templates/$billingId/send_invoice/", [ + 'json' => $client, + ]); + + return Purchase::fromArray((array) $response); + } + + public function addSubscriber(string $billingId, BillingTemplateClient $client): BillingTemplateClientAddSubscriber + { + $response = $this->client->request('POST', "billing_templates/$billingId/add_subscriber/", [ + 'json' => $client, + ]); + + return BillingTemplateClientAddSubscriber::fromArray((array) $response); + } + + public function listClients(string $billingId): BillingTemplateClientList + { + $response = $this->client->request('GET', "billing_templates/$billingId/clients/"); + + return BillingTemplateClientList::fromArray((array) $response); + } + + /** + * @return \Generator + */ + public function iterateClients(string $billingId): \Generator + { + $url = "billing_templates/$billingId/clients/"; + + while ($url !== null) { + $response = $this->client->request('GET', $url); + $list = BillingTemplateClientList::fromArray((array) $response); + + foreach ($list->results ?? [] as $client) { + yield $client; + } + + $url = $list->next; + } + } + + public function getClient(string $billingId, string $clientId): BillingTemplateClient + { + $response = $this->client->request('GET', "billing_templates/$billingId/clients/$clientId/"); + + return BillingTemplateClient::fromArray((array) $response); + } + + public function updateClient(string $billingId, string $clientId, BillingTemplateClient $client): BillingTemplateClient + { + $response = $this->client->request('PATCH', "billing_templates/$billingId/clients/$clientId/", [ + 'json' => $client, + ]); + + return BillingTemplateClient::fromArray((array) $response); + } +} diff --git a/lib/Resource/ClientsResource.php b/lib/Resource/ClientsResource.php new file mode 100644 index 0000000..0afac8f --- /dev/null +++ b/lib/Resource/ClientsResource.php @@ -0,0 +1,102 @@ +client->request('POST', 'clients/', [ + 'json' => $client, + ]); + + return ClientDetails::fromArray((array) $response); + } + + public function list(): ClientList + { + $response = $this->client->request('GET', 'clients/'); + + return ClientList::fromArray((array) $response); + } + + /** + * @return \Generator + */ + public function iterate(): \Generator + { + $url = 'clients/'; + + while ($url !== null) { + $response = $this->client->request('GET', $url); + $list = ClientList::fromArray((array) $response); + + foreach ($list->results ?? [] as $client) { + yield $client; + } + + $url = $list->next; + } + } + + public function get(string $clientId): ClientDetails + { + $response = $this->client->request('GET', "clients/$clientId/"); + + return ClientDetails::fromArray((array) $response); + } + + public function update(string $clientId, ClientDetails $client): ClientDetails + { + $response = $this->client->request('PUT', "clients/$clientId/", [ + 'json' => $client, + ]); + + return ClientDetails::fromArray((array) $response); + } + + public function partialUpdate(string $clientId, ClientDetails $client): ClientDetails + { + $response = $this->client->request('PATCH', "clients/$clientId/", [ + 'json' => $client, + ]); + + return ClientDetails::fromArray((array) $response); + } + + public function delete(string $clientId): void + { + $this->client->request('DELETE', "clients/$clientId/"); + } + + public function listRecurringTokens(string $clientId): ClientRecurringTokenList + { + $response = $this->client->request('GET', "clients/$clientId/recurring_tokens/"); + + return ClientRecurringTokenList::fromArray((array) $response); + } + + public function getRecurringToken(string $clientId, string $purchaseId): ClientRecurringToken + { + $response = $this->client->request('GET', "clients/$clientId/recurring_tokens/$purchaseId/"); + + return ClientRecurringToken::fromArray((array) $response); + } + + public function deleteRecurringToken(string $clientId, string $purchaseId): void + { + $this->client->request('DELETE', "clients/$clientId/recurring_tokens/$purchaseId/"); + } +} diff --git a/lib/Resource/PaymentMethodsResource.php b/lib/Resource/PaymentMethodsResource.php new file mode 100644 index 0000000..d012b2d --- /dev/null +++ b/lib/Resource/PaymentMethodsResource.php @@ -0,0 +1,32 @@ + $options + */ + public function list(string $currency = 'MYR', array $options = []): PaymentMethods + { + $response = $this->client->request('GET', 'payment_methods/', [ + 'query' => array_merge([ + 'brand_id' => $this->brandId, + 'currency' => $currency, + ], $options), + ]); + + return PaymentMethods::fromArray((array) $response); + } +} diff --git a/lib/Resource/PublicKeyResource.php b/lib/Resource/PublicKeyResource.php new file mode 100644 index 0000000..306eced --- /dev/null +++ b/lib/Resource/PublicKeyResource.php @@ -0,0 +1,22 @@ +client->request('GET', 'public_key/'); + $arr = (array) $response; + + return $arr['public_key'] ?? ''; + } +} diff --git a/lib/Resource/PurchasesResource.php b/lib/Resource/PurchasesResource.php new file mode 100644 index 0000000..6edbde9 --- /dev/null +++ b/lib/Resource/PurchasesResource.php @@ -0,0 +1,104 @@ +client->request('POST', 'purchases/', [ + 'json' => $purchase, + ]); + + return Purchase::fromArray((array) $response); + } + + public function get(string $purchaseId): Purchase + { + $response = $this->client->request('GET', "purchases/$purchaseId/"); + + return Purchase::fromArray((array) $response); + } + + public function cancel(string $purchaseId): Purchase + { + $response = $this->client->request('POST', "purchases/$purchaseId/cancel/"); + + return Purchase::fromArray((array) $response); + } + + public function release(string $purchaseId): Purchase + { + $response = $this->client->request('POST', "purchases/$purchaseId/release/"); + + return Purchase::fromArray((array) $response); + } + + public function capture(string $purchaseId, ?int $amount = null): Purchase + { + $options = []; + if ($amount !== null) { + $options['json'] = ['amount' => $amount]; + } + + $response = $this->client->request('POST', "purchases/$purchaseId/capture/", $options); + + return Purchase::fromArray((array) $response); + } + + public function charge(string $purchaseId, string $token): Purchase + { + $response = $this->client->request('POST', "purchases/$purchaseId/charge/", [ + 'json' => ['recurring_token' => $token], + ]); + + return Purchase::fromArray((array) $response); + } + + public function deleteRecurringToken(string $purchaseId): Purchase + { + $response = $this->client->request('POST', "purchases/$purchaseId/delete_recurring_token/"); + + return Purchase::fromArray((array) $response); + } + + public function refund(string $purchaseId, ?int $amount = null): Purchase + { + $options = []; + if ($amount !== null) { + $options['json'] = ['amount' => $amount]; + } + + $response = $this->client->request('POST', "purchases/$purchaseId/refund/", $options); + + return Purchase::fromArray((array) $response); + } + + public function markAsPaid(string $purchaseId, ?int $utcTimestamp = null): Purchase + { + $options = []; + if ($utcTimestamp !== null) { + $options['json'] = ['paid_on' => $utcTimestamp]; + } + + $response = $this->client->request('POST', "purchases/$purchaseId/mark_as_paid/", $options); + + return Purchase::fromArray((array) $response); + } + + public function resendInvoice(string $purchaseId): Purchase + { + $response = $this->client->request('POST', "purchases/$purchaseId/resend_invoice/"); + + return Purchase::fromArray((array) $response); + } +} diff --git a/lib/Resource/StatementsResource.php b/lib/Resource/StatementsResource.php new file mode 100644 index 0000000..65ad66b --- /dev/null +++ b/lib/Resource/StatementsResource.php @@ -0,0 +1,69 @@ + $filters + */ + public function schedule(CompanyStatement $statement, array $filters = []): CompanyStatement + { + $response = $this->client->request('POST', 'company_statements/', [ + 'query' => $filters, + 'json' => $statement, + ]); + + return CompanyStatement::fromArray((array) $response); + } + + public function list(): CompanyStatementList + { + $response = $this->client->request('GET', 'company_statements/'); + + return CompanyStatementList::fromArray((array) $response); + } + + /** + * @return \Generator + */ + public function iterate(): \Generator + { + $url = 'company_statements/'; + + while ($url !== null) { + $response = $this->client->request('GET', $url); + $list = CompanyStatementList::fromArray((array) $response); + + foreach ($list->results ?? [] as $statement) { + yield $statement; + } + + $url = $list->next; + } + } + + public function get(string $statementId): CompanyStatement + { + $response = $this->client->request('GET', "company_statements/$statementId/"); + + return CompanyStatement::fromArray((array) $response); + } + + public function cancel(string $statementId): CompanyStatement + { + $response = $this->client->request('POST', "company_statements/$statementId/cancel/"); + + return CompanyStatement::fromArray((array) $response); + } +} diff --git a/lib/Resource/WebhooksResource.php b/lib/Resource/WebhooksResource.php new file mode 100644 index 0000000..e66d50b --- /dev/null +++ b/lib/Resource/WebhooksResource.php @@ -0,0 +1,81 @@ +client->request('POST', 'webhooks/', [ + 'json' => $webhook, + ]); + + return Webhook::fromArray((array) $response); + } + + public function list(): WebhookList + { + $response = $this->client->request('GET', 'webhooks/'); + + return WebhookList::fromArray((array) $response); + } + + /** + * @return \Generator + */ + public function iterate(): \Generator + { + $url = 'webhooks/'; + + while ($url !== null) { + $response = $this->client->request('GET', $url); + $list = WebhookList::fromArray((array) $response); + + foreach ($list->results ?? [] as $webhook) { + yield $webhook; + } + + $url = $list->next; + } + } + + public function get(string $webhookId): Webhook + { + $response = $this->client->request('GET', "webhooks/$webhookId/"); + + return Webhook::fromArray((array) $response); + } + + public function update(string $webhookId, Webhook $webhook): Webhook + { + $response = $this->client->request('PUT', "webhooks/$webhookId/", [ + 'json' => $webhook, + ]); + + return Webhook::fromArray((array) $response); + } + + public function partialUpdate(string $webhookId, Webhook $webhook): Webhook + { + $response = $this->client->request('PATCH', "webhooks/$webhookId/", [ + 'json' => $webhook, + ]); + + return Webhook::fromArray((array) $response); + } + + public function delete(string $webhookId): void + { + $this->client->request('DELETE', "webhooks/$webhookId/"); + } +} diff --git a/lib/Traits/Api/Billing.php b/lib/Traits/Api/Billing.php deleted file mode 100644 index cb2325f..0000000 --- a/lib/Traits/Api/Billing.php +++ /dev/null @@ -1,113 +0,0 @@ -request('POST', 'billing/', [ - 'json' => $billing - ]); - } - - /** - * Create a template to issue repeated invoices from in the future, with or without a subscription. - */ - public function createBillingTemplate(BillingTemplate $billing) - { - return $this->mapper->map($this->request('POST', 'billing_templates/', [ - 'json' => $billing - ]), new BillingTemplate()); - } - - /** - * List all billing templates. - */ - public function getBillingTemplates() - { - return $this->mapper->map($this->request('GET', 'billing_templates/'), new BillingTemplateList()); - } - - /** - * Retrieve a billing template by ID. - */ - public function getBillingTemplate(string $billing_id) - { - return $this->mapper->map($this->request('GET', "billing_templates/$billing_id/"), new BillingTemplate()); - } - - /** - * Update a billing template by ID. - */ - public function updateBillingTemplate(string $billing_id, BillingTemplate $billing) - { - return $this->mapper->map($this->request('PUT', "billing_templates/$billing_id/", [ - 'json' => $billing - ]), new BillingTemplate()); - } - - /** - * Delete a billing template by ID. - */ - public function deleteBillingTemplate(string $billing_id) - { - return $this->request('DELETE', "billing_templates/$billing_id/"); - } - - /** - * Send an invoice, generating a purchase from billing template data. - */ - public function sendBillingTemplateInvoice(string $billing_id, BillingTemplateClient $billingTemplateClient) - { - return $this->mapper->map($this->request('POST', "billing_templates/$billing_id/send_invoice/", [ - 'json' => $billingTemplateClient - ]), new Purchase()); - } - - /** - * Add a billing template client and activate recurring billing (is_subscription: true). - */ - public function addBillingTemplateSubscriber(string $billing_id, BillingTemplateClient $billingTemplateClient) - { - return $this->mapper->map($this->request('POST', "billing_templates/$billing_id/add_subscriber/", [ - 'json' => $billingTemplateClient - ]), new BillingTemplateClientAddSubscriber()); - } - - /** - * List all billing template clients for this billing template. - */ - public function getBillingTemplateClients(string $billing_id) - { - return $this->mapper->map($this->request('GET', "billing_templates/$billing_id/clients/"), new BillingTemplateClientList()); - } - - /** - * Retrieve a billing template client by client's ID. - */ - public function getBillingTemplateClient(string $billing_id, string $billing_client_id) - { - return $this->mapper->map($this->request('GET', "billing_templates/$billing_id/clients/$billing_client_id/"), new BillingTemplateClient()); - } - - /** - * Partially update a billing template client by client's ID. - */ - public function updateBillingTemplateClient(string $billing_id, string $billing_client_id, BillingTemplateClient $billingTemplateClient) - { - return $this->mapper->map($this->request('PATCH', "billing_templates/$billing_id/clients/$billing_client_id/", [ - 'json' => $billingTemplateClient - ]), new BillingTemplateClient()); - } -} diff --git a/lib/Traits/Api/Client.php b/lib/Traits/Api/Client.php deleted file mode 100644 index 343c2c2..0000000 --- a/lib/Traits/Api/Client.php +++ /dev/null @@ -1,26 +0,0 @@ -mapper->map($this->request('POST', 'clients/', [ - 'json' => $client - ]), new ModelClientDetails()); - } - - public function getClients() - { - return $this->mapper->map($this->request('GET', 'clients/'), new ClientList()); - } -} diff --git a/lib/Traits/Api/PaymentMethod.php b/lib/Traits/Api/PaymentMethod.php deleted file mode 100644 index 54595b9..0000000 --- a/lib/Traits/Api/PaymentMethod.php +++ /dev/null @@ -1,23 +0,0 @@ -mapper->map($this->request('GET', 'payment_methods/', [ - 'query' => [ - 'brand_id' => $this->brandId, - 'currency' => $currency - ] - ]), new ModelPaymentMethods()); - } -} \ No newline at end of file diff --git a/lib/Traits/Api/Purchase.php b/lib/Traits/Api/Purchase.php deleted file mode 100644 index 6b1d3e5..0000000 --- a/lib/Traits/Api/Purchase.php +++ /dev/null @@ -1,126 +0,0 @@ -mapper->map($this->request('POST', 'purchases/', [ - 'json' => $purchase - ]), new ModelPurchase()); - } - - /** - * - * @param string $purchaseId - * @return \Chip\Model\Purchase - */ - public function getPurchase(string $purchaseId): ModelPurchase - { - return $this->mapper->map($this->request('GET', "purchases/$purchaseId/"), new ModelPurchase()); - } - - /** - * - * @param string $purchaseId - * @return \Chip\Model\Purchase - */ - public function cancelPurchase(string $purchaseId): ModelPurchase - { - return $this->mapper->map($this->request('POST', "purchases/$purchaseId/cancel/"), new ModelPurchase()); - } - - /** - * - * @param string $purchaseId - * @return \Chip\Model\Purchase - */ - public function releasePurchase(string $purchaseId): ModelPurchase - { - return $this->mapper->map($this->request('POST', "purchases/$purchaseId/release/"), new ModelPurchase()); - } - - /** - * - * @param string $purchaseId - * @param int $amount - * @return \Chip\Model\Purchase - */ - public function capturePurchase(string $purchaseId, ?int $amount = null): ModelPurchase - { - $options = []; - if ($amount !== null) { - $options['json'] = [ - 'amount' => $amount - ]; - } - return $this->mapper->map($this->request('POST', "purchases/$purchaseId/capture/", $options), new ModelPurchase()); - } - - /** - * - * @param string $purchaseId - * @param string $token - * @return \Chip\Model\Purchase - */ - public function chargePurchase(string $purchaseId, string $token): ModelPurchase - { - return $this->mapper->map($this->request('POST', "purchases/$purchaseId/charge/", [ - 'json' => [ - 'recurring_token' => $token - ] - ]), new ModelPurchase()); - } - - /** - * - * @param string $purchaseId - * @return \Chip\Model\Purchase - */ - public function deleteRecurringToken(string $purchaseId): ModelPurchase - { - return $this->mapper->map($this->request('POST', "purchases/$purchaseId/delete_recurring_token/"), new ModelPurchase()); - } - - /** - * - * @param string $purchaseId - * @param int $amount - * @return \Chip\Model\Purchase - */ - public function refundPurchase(string $purchaseId, ?int $amount = null): ModelPurchase - { - $options = []; - if ($amount !== null) { - $options['json'] = [ - 'amount' => $amount - ]; - } - return $this->mapper->map($this->request('POST', "purchases/$purchaseId/refund/", $options), new ModelPurchase()); - } - - /** - * - * @param string $purchaseId - * @param int $utcTimestamp - * @return \Chip\Model\Purchase - */ - public function markAsPaid(string $purchaseId, ?int $utcTimestamp = null): ModelPurchase - { - $options = []; - if ($utcTimestamp !== null) { - $options['json'] = [ - 'paid_on' => $utcTimestamp - ]; - } - return $this->mapper->map($this->request('POST', "purchases/$purchaseId/mark_as_paid/", $options), new ModelPurchase()); - } -} \ No newline at end of file diff --git a/lib/Traits/Api/Webhook.php b/lib/Traits/Api/Webhook.php deleted file mode 100644 index 5095d38..0000000 --- a/lib/Traits/Api/Webhook.php +++ /dev/null @@ -1,40 +0,0 @@ -mapper->map($this->request('POST', 'webhooks/', [ - 'json' => $webhook - ]), new ModelWebHook()); - } - - /** - * - * @param string $webhookId - * @return \Chip\Model\Webhook - */ - public function getWebhook(string $webhookId): ModelWebHook - { - return $this->mapper->map($this->request('GET', "webhooks/$webhookId/"), new ModelWebHook()); - } - - /** - * - * @param string $webhookId - * @return mixed - */ - public function deleteWebhook(string $webhookId): mixed - { - return $this->request('DELETE', "webhooks/$webhookId/"); - } -} \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..81c2ea3 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,9 @@ +includes: + - vendor/phpstan/phpstan/conf/bleedingEdge.neon + +parameters: + level: 8 + paths: + - lib + - tests + ignoreErrors: diff --git a/scripts/generate_pr_summary.py b/scripts/generate_pr_summary.py new file mode 100644 index 0000000..103d6dd --- /dev/null +++ b/scripts/generate_pr_summary.py @@ -0,0 +1,99 @@ +import os +import sys +import requests + + +def generate_summary(diff_text: str, current_body: str, api_key: str, model: str = "gemini-3-flash-preview:cloud") -> str: + url = os.getenv("OLLAMA_API_URL", "https://api.ollama.com/api/generate") + + prompt = f"""You are a senior software engineer. Please review the following git diff and generate a Pull Request description. + +Current PR Body (if any): +{current_body} + +Git Diff: +{diff_text} + +Please generate a Pull Request description that follows this exact format: + +## What does this change? +[Provide a detailed explanation of WHAT the problem was and HOW this change solves it. Focus on the 'why' and 'how'.] + +## Asana / Jira / Trello task link + + +## How to test +[Provide step-by-step instructions to help others verify the change. Suggest specific tests based on the modified files.] + +## Potential Risks & Senior Review Items +[Identify potential side effects, performance implications, security considerations, or architectural concerns. Highlight specific areas where a senior engineer should focus their review.] + +## Is this PR warrant an automatic approval? +[Yes/No. Provide a brief justification based on the complexity and risk of the changes.] + +## Images + + +Important: +- If the 'Current PR Body' already contains information (like task links or images), PRESERVE them in the new summary. +- Fill in the 'What does this change?', 'How to test', 'Potential Risks & Senior Review Items', and 'Is this PR warrant an automatic approval?' sections based on the provided diff. +- Keep the other sections exactly as shown (with their HTML comments/placeholders) so the user can fill them in manually if needed. +- Return ONLY the markdown content. +""" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + data = { + "model": model, + "prompt": prompt, + "stream": False, + } + + try: + response = requests.post(url, headers=headers, json=data, timeout=90) + response.raise_for_status() + result = response.json() + return result.get("response", "Could not generate summary.") + except requests.exceptions.RequestException as e: + return f"Error calling Ollama API: {e!s}" + + +def main() -> int: + if len(sys.argv) < 2: + print("Usage: python generate_pr_summary.py [current_body_file]") + return 1 + + diff_file = sys.argv[1] + current_body_file = sys.argv[2] if len(sys.argv) > 2 else None + + api_key = os.getenv("OLLAMA_API_KEY") + if not api_key: + print("Error: OLLAMA_API_KEY environment variable not set.") + return 1 + + if not os.path.exists(diff_file): + print(f"Error: File {diff_file} not found.") + return 1 + + with open(diff_file, encoding="utf-8") as f: + diff_text = f.read() + + current_body = "" + if current_body_file and os.path.exists(current_body_file): + with open(current_body_file, encoding="utf-8") as f: + current_body = f.read() + + # Limit diff size to avoid token limits + if len(diff_text) > 50000: + diff_text = diff_text[:50000] + "\n\n... (diff truncated for size) ..." + + summary = generate_summary(diff_text, current_body, api_key) + print(summary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test.php b/test.php deleted file mode 100644 index c8d1524..0000000 --- a/test.php +++ /dev/null @@ -1,82 +0,0 @@ -email = 'test@example.com'; -// $purchase = new \Chip\Model\Purchase(); -// $purchase->client = $client; -// $details = new \Chip\Model\PurchaseDetails(); -// $product = new \Chip\Model\Product(); -// $product->name = 'test product ' . date('Y-m-d H:i:s e'); -// $product->price = 100; -// $details->products = [$product]; -// $purchase->purchase = $details; -// $purchase->brand_id = $env["BRAND_ID"]; -// $purchase->success_redirect = 'https://yourdomain.com/redirect.php?success=1'; -// $purchase->success_callback = 'https://yourdomain.com/callback.php?success=0'; -// $purchase->failure_redirect = 'https://yourdomain.com/redirect.php?success=0'; - -/** Billing */ -$billing = new BillingTemplate(); - -// $client = new BillingTemplateClient(); -// $client->client_id = "6c761052-b64e-48f4-a68b-b0b7ef1935a2"; -// $billing->clients = [$client]; - -$purchaseDetails = new \Chip\Model\PurchaseDetails(); -$product = new \Chip\Model\Product(); -$product->name = 'test product ' . date('Y-m-d H:i:s e'); -$product->price = 100; -$purchaseDetails->products = [$product]; -$billing->purchase = $purchaseDetails; -// $billing->is_subscription = true; -$billing->brand_id = $env["BRAND_ID"]; -// $billing->invoice_due = time() + 100; -// $billing->title = 'test createBillingTemplate ' . date('Y-m-d H:i:s e'); -// $result = $chip->getClients(); -// $result = $chip->createBilling($billing); - -// $billing->subscription_period = 1; -// $billing->subscription_period_units = "months"; -$billing->subscription_due_period = 7; -$billing->subscription_due_period_units = "days"; -// $billing->subscription_charge_period_end = true; -// $billing->subscription_trial_periods = 1; -$billing->subscription_active = true; -// $result = $chip->createBillingTemplate($billing); - -$result = $chip->getBillingTemplates(); - -// $result = $chip->getBillingTemplate("bab94920-5078-4b08-9cbc-13e78549652f"); - -// $billing->title = 'test updateBillingTemplate ' . date('Y-m-d H:i:s e'); -// $result = $chip->updateBillingTemplate("bab94920-5078-4b08-9cbc-13e78549652f", $billing); - -// $result = $chip->deleteBillingTemplate("d2ee0722-c3ac-40da-a3f3-b9cd8084c596"); - -// $billing_client = new BillingTemplateClient(); -// $billing_client->client_id = "6c761052-b64e-48f4-a68b-b0b7ef1935a2"; -// $result = $chip->sendBillingTemplateInvoice("d2ee0722-c3ac-40da-a3f3-b9cd8084c596", $billing_client); - -// $billing_client = new BillingTemplateClient(); -// $billing_client->client_id = "6c761052-b64e-48f4-a68b-b0b7ef1935a2"; -// $result = $chip->addBillingTemplateSubscriber("d2ee0722-c3ac-40da-a3f3-b9cd8084c596", $billing_client); - -// $result = $chip->getBillingTemplateClients("d2ee0722-c3ac-40da-a3f3-b9cd8084c596"); - -// $result = $chip->getBillingTemplateClient("d2ee0722-c3ac-40da-a3f3-b9cd8084c596", "3efa3608-67cf-40fc-bed4-b55da4c87eb8"); - -$billing_client = new BillingTemplateClient(); -$billing_client->status = 'active'; -$result = $chip->updateBillingTemplateClient("d2ee0722-c3ac-40da-a3f3-b9cd8084c596", "3efa3608-67cf-40fc-bed4-b55da4c87eb8", $billing_client); - -var_dump($result); diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 0c087f2..b0d1882 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -1,4 +1,6 @@ -getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->refundPurchase($this->purchase_id); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/refund", $transaction['request']->getUri()->getPath()); - $this->assertEmpty($transaction['request']->getBody()->getContents()); - } - - public function testRefundWithAmount() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->refundPurchase($this->purchase_id, 100); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/refund", $transaction['request']->getUri()->getPath()); - $body = json_decode($transaction['request']->getBody()->getContents(), true); - $this->assertEquals(100, $body['amount']); - } - - public function testPaymentMethods() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->getPaymentMethods('USD'); - $transaction = $container[0]; - - $this->assertEquals('GET', $transaction['request']->getMethod()); - $this->assertStringContainsString('payment_methods/', $transaction['request']->getUri()->getPath()); - $body = json_decode($transaction['request']->getBody()->getContents(), true); - $this->assertStringContainsString('currency=USD', $transaction['request']->getUri()->getQuery()); - } - - public function testCreatePurchase() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $model = new ModelPurchase(); - $api->createPurchase($model); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString('purchases/', $transaction['request']->getUri()->getPath()); - } - - public function testGetPurchase() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->getPurchase($this->purchase_id); - $transaction = $container[0]; - - $this->assertEquals('GET', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/", $transaction['request']->getUri()->getPath()); - } - - public function testCancelPurchase() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->cancelPurchase($this->purchase_id); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/cancel", $transaction['request']->getUri()->getPath()); - } - - public function testRelasePurchase() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->releasePurchase($this->purchase_id); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/release", $transaction['request']->getUri()->getPath()); - } - - public function testCaptureWithoutAmount() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->capturePurchase($this->purchase_id); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/capture", $transaction['request']->getUri()->getPath()); - $this->assertEmpty($transaction['request']->getBody()->getContents()); - } - - public function testCaptureWithAmount() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->capturePurchase($this->purchase_id, 100); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/capture", $transaction['request']->getUri()->getPath()); - $body = json_decode($transaction['request']->getBody()->getContents(), true); - $this->assertEquals(100, $body['amount']); - } - - public function testChargePurchase() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->chargePurchase($this->purchase_id, 'token'); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/charge", $transaction['request']->getUri()->getPath()); - $body = json_decode($transaction['request']->getBody()->getContents(), true); - $this->assertEquals('token', $body['recurring_token']); - } - - public function testDeleteRecurringToken() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->deleteRecurringToken($this->purchase_id); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/delete_recurring_token", $transaction['request']->getUri()->getPath()); - } - - public function testVerify() { - $content = '{"id": "", "due": 1642060235, "type": "purchase", "client": {"cc": [], "bcc": [], "city": "", "email": "", "phone": "", "country": "", "zip_code": "", "bank_code": "", "full_name": "", "brand_name": "", "legal_name": "", "tax_number": "", "client_type": null, "bank_account": "", "personal_code": "", "shipping_city": "", "street_address": "", "shipping_country": "", "shipping_zip_code": "", "registration_number": "", "shipping_street_address": ""}, "issued": "", "status": "created", "is_test": true, "payment": null, "product": "purchases", "user_id": null, "brand_id": "", "order_id": null, "platform": "api", "purchase": {"debt": 0, "notes": "", "total": 100, "currency": "EUR", "language": "en", "products": [{"name": "test", "price": 100, "category": "", "discount": 0, "quantity": "1.0000", "tax_percent": "0.00"}], "timezone": "UTC", "due_strict": false, "email_message": "", "total_override": null, "shipping_options": [], "subtotal_override": null, "total_tax_override": null, "payment_method_details": {}, "request_client_details": [], "total_discount_override": null}, "client_id": null, "reference": "", "viewed_on": null, "company_id": "", "created_on": 1642056635, "event_type": "purchase.created", "updated_on": 1642056635, "invoice_url": null, "checkout_url": "", "send_receipt": false, "skip_capture": false, "creator_agent": "", "issuer_details": {"website": "", "brand_name": "", "legal_city": "", "legal_name": "", "tax_number": "", "bank_accounts": [{"bank_code": "", "bank_account": ""}], "legal_country": "", "legal_zip_code": "", "registration_number": "", "legal_street_address": ""}, "marked_as_paid": false, "status_history": [{"status": "created", "timestamp": 1642056635}], "cancel_redirect": "", "created_from_ip": "", "direct_post_url": null, "force_recurring": false, "recurring_token": null, "failure_redirect": "", "success_callback": "", "success_redirect": "", "transaction_data": {"flow": "payform", "extra": {}, "country": "", "attempts": [], "payment_method": ""}, "refundable_amount": 0, "is_recurring_token": false, "billing_template_id": null, "currency_conversion": null, "reference_generated": "", "refund_availability": "none", "payment_method_whitelist": null}'; - $signature = 'dHgVBR7qLldrgjMAM0exDnDIBsUU0ZpQC4lkPhAjmjZjkFlRoIYcaC4fR03avykxujZwakM1mGjvInFvCHE8zrrUemeJhHSHN+8n54zecQQ0U84JhdDufr0bSXvSduaqLW1cbBEOHKXm4UCVkMp3bRKzPGEYLM0L6PYd00x3yY53gDeOm05HWlXb5UG8hpKHJPhhr5S58r+hStlM0yAI7tkeTTy6neIin7WKS8imeiGGRh6n46mXEtIcwMzmOaRmQ7me3GAxvD8gDEPY6JV6r3eQZpTF7iX/rU0pod0P35XTvQ3pO2HMBCeRm5zfFCva9JGEVvtiJ1ZDZO/4/UfPEQ=='; - $publicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArzedRaG/aa191+f3/Syf\nye4lbwaVDngwBpsV/JidZ3T/27oEAPtwZ3oqhmhsBQcVB/f94ecFdj49NTG1DZZN\nfkWjSZEViL22oEGBryK2MjkUrW30kY1Yh0vCa/e0nIG/+9b1TLfzHIwjm54hw1R/\nRi/m/tf1nLMEm06ogDNV/AUyg6uyNLqp21NxKP7+xV6yfPkfX1s+qSjciyCPzO6r\n+TsG3GTqopG1FSaWx+R0+bmsOEmV5YQKMUlLKlf0wJUD7mjsNioFomEp5QBpASbE\nLfNDO13L5FiUgLtWcz+ZazCZmNUdhstLvrEVt8NhvPWBy96YWm4GfXx7xr8F11yH\npQIDAQAB\n-----END PUBLIC KEY-----"; - - $this->assertTrue(\Chip\ChipApi::verify($content, $signature, $publicKey)); - } - - public function testMarkAsPaid() { - $container = []; - $history = Middleware::history($container); - $api = $this->getMockApi(new MockHandler([ - new Response(200, [], '{}') - ]), $history); - $api->markAsPaid($this->purchase_id); - $transaction = $container[0]; - - $this->assertEquals('POST', $transaction['request']->getMethod()); - $this->assertStringContainsString("purchases/$this->purchase_id/mark_as_paid/", $transaction['request']->getUri()->getPath()); - } - - protected function getMockApi($mock, $history) { - $env = parse_ini_file('.env'); - $handlerStack = HandlerStack::create($mock); - $handlerStack->push($history); - return new \Chip\ChipApi($env["BRAND_ID"], $env["API_KEY"], 'https://gate.chip-in.asia/api/v1/', [ - 'handler' => $handlerStack - ]); - } - -} \ No newline at end of file + public function testRefundWithoutAmount(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->refund('123'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/refund', $transaction['request']->getUri()->getPath()); + $this->assertEmpty($transaction['request']->getBody()->getContents()); + } + + public function testRefundWithAmount(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->refund('123', 100); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/refund', $transaction['request']->getUri()->getPath()); + $body = json_decode($transaction['request']->getBody()->getContents(), true); + $this->assertEquals(100, $body['amount']); + } + + public function testPaymentMethods(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->paymentMethods->list('MYR'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('payment_methods/', $transaction['request']->getUri()->getPath()); + $body = json_decode($transaction['request']->getBody()->getContents(), true); + $this->assertStringContainsString('currency=MYR', $transaction['request']->getUri()->getQuery()); + } + + public function testCreatePurchase(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->create(new \Chip\Model\Purchase()); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/', $transaction['request']->getUri()->getPath()); + } + + public function testGetPurchase(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->get('123'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/', $transaction['request']->getUri()->getPath()); + } + + public function testCancelPurchase(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->cancel('123'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/cancel', $transaction['request']->getUri()->getPath()); + } + + public function testRelasePurchase(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->release('123'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/release', $transaction['request']->getUri()->getPath()); + } + + public function testCaptureWithoutAmount(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->capture('123'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/capture', $transaction['request']->getUri()->getPath()); + $this->assertEmpty($transaction['request']->getBody()->getContents()); + } + + public function testCaptureWithAmount(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->capture('123', 100); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/capture', $transaction['request']->getUri()->getPath()); + $body = json_decode($transaction['request']->getBody()->getContents(), true); + $this->assertEquals(100, $body['amount']); + } + + public function testChargePurchase(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->charge('123', 'token'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/charge', $transaction['request']->getUri()->getPath()); + $body = json_decode($transaction['request']->getBody()->getContents(), true); + $this->assertEquals('token', $body['recurring_token']); + } + + public function testDeleteRecurringToken(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->deleteRecurringToken('123'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/delete_recurring_token', $transaction['request']->getUri()->getPath()); + } + + public function testVerify(): void + { + $content = '{"id": "", "due": 1642060235, "type": "purchase", "client": {"cc": [], "bcc": [], "city": "", "email": "", "phone": "", "country": "", "zip_code": "", "bank_code": "", "full_name": "", "brand_name": "", "legal_name": "", "tax_number": "", "client_type": null, "bank_account": "", "personal_code": "", "shipping_city": "", "street_address": "", "shipping_country": "", "shipping_zip_code": "", "registration_number": "", "shipping_street_address": ""}, "issued": "", "status": "created", "is_test": true, "payment": null, "product": "purchases", "user_id": null, "brand_id": "", "order_id": null, "platform": "api", "purchase": {"debt": 0, "notes": "", "total": 100, "currency": "EUR", "language": "en", "products": [{"name": "test", "price": 100, "category": "", "discount": 0, "quantity": "1.0000", "tax_percent": "0.00"}], "timezone": "UTC", "due_strict": false, "email_message": "", "total_override": null, "shipping_options": [], "subtotal_override": null, "total_tax_override": null, "payment_method_details": {}, "request_client_details": [], "total_discount_override": null}, "client_id": null, "reference": "", "viewed_on": null, "company_id": "", "created_on": 1642056635, "event_type": "purchase.created", "updated_on": 1642056635, "invoice_url": null, "checkout_url": "", "send_receipt": false, "skip_capture": false, "creator_agent": "", "issuer_details": {"website": "", "brand_name": "", "legal_city": "", "legal_name": "", "tax_number": "", "bank_accounts": [{"bank_code": "", "bank_account": ""}], "legal_country": "", "legal_zip_code": "", "registration_number": "", "legal_street_address": ""}, "marked_as_paid": false, "status_history": [{"status": "created", "timestamp": 1642056635}], "cancel_redirect": "", "created_from_ip": "", "direct_post_url": null, "force_recurring": false, "recurring_token": null, "failure_redirect": "", "success_callback": "", "success_redirect": "", "transaction_data": {"flow": "payform", "extra": {}, "country": "", "attempts": [], "payment_method": ""}, "refundable_amount": 0, "is_recurring_token": false, "billing_template_id": null, "currency_conversion": null, "reference_generated": "", "refund_availability": "none", "payment_method_whitelist": null}'; + $signature = 'dHgVBR7qLldrgjMAM0exDnDIBsUU0ZpQC4lkPhAjmjZjkFlRoIYcaC4fR03avykxujZwakM1mGjvInFvCHE8zrrUemeJhHSHN+8n54zecQQ0U84JhdDufr0bSXvSduaqLW1cbBEOHKXm4UCVkMp3bRKzPGEYLM0L6PYd00x3yY53gDeOm05HWlXb5UG8hpKHJPhhr5S58r+hStlM0yAI7tkeTTy6neIin7WKS8imeiGGRh6n46mXEtIcwMzmOaRmQ7me3GAxvD8gDEPY6JV6r3eQZpTF7iX/rU0pod0P35XTvQ3pO2HMBCeRm5zfFCva9JGEVvtiJ1ZDZO/4/UfPEQ=='; + $publicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArzedRaG/aa191+f3/Syf\nye4lbwaVDngwBpsV/JidZ3T/27oEAPtwZ3oqhmhsBQcVB/f94ecFdj49NTG1DZZN\nfkWjSZEViL22oEGBryK2MjkUrW30kY1Yh0vCa/e0nIG/+9b1TLfzHIwjm54hw1R/\nRi/m/tf1nLMEm06ogDNV/AUyg6uyNLqp21NxKP7+xV6yfPkfX1s+qSjciyCPzO6r\n+TsG3GTqopG1FSaWx+R0+bmsOEmV5YQKMUlLKlf0wJUD7mjsNioFomEp5QBpASbE\nLfNDO13L5FiUgLtWcz+ZazCZmNUdhstLvrEVt8NhvPWBy96YWm4GfXx7xr8F11yH\npQIDAQAB\n-----END PUBLIC KEY-----"; + + $this->assertTrue(\Chip\ChipApi::verify($content, $signature, $publicKey)); + } + + public function testGetPurchaseMapsResponseToModel(): void + { + $responseBody = $this->jsonResponse([ + 'id' => 'purchase_123', + 'status' => 'created', + 'brand_id' => 'brand_456', + 'purchase' => [ + 'currency' => 'EUR', + 'total' => 100, + 'products' => [ + ['name' => 'Test Product', 'price' => 100, 'quantity' => 1], + ], + ], + 'client' => [ + 'email' => 'test@example.com', + ], + ]); + + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], $responseBody), + ]), Middleware::history($container)); + + $purchase = $api->purchases->get('purchase_123'); + + $this->assertInstanceOf(\Chip\Model\Purchase::class, $purchase); + $this->assertEquals('purchase_123', $purchase->id); + $this->assertEquals('created', $purchase->status); + $this->assertEquals('brand_456', $purchase->brand_id); + $this->assertInstanceOf(\Chip\Model\PurchaseDetails::class, $purchase->purchase); + $this->assertEquals('EUR', $purchase->purchase->currency); + $this->assertEquals(100, $purchase->purchase->total); + $this->assertCount(1, $purchase->purchase->products); + $this->assertInstanceOf(\Chip\Model\Product::class, $purchase->purchase->products[0]); + $this->assertEquals('Test Product', $purchase->purchase->products[0]->name); + $this->assertInstanceOf(\Chip\Model\ClientDetails::class, $purchase->client); + $this->assertEquals('test@example.com', $purchase->client->email); + } + + public function testAuthenticationException(): void + { + $this->expectException(\Chip\Exception\AuthenticationException::class); + $this->expectExceptionMessage('Invalid API key'); + + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(401, [], $this->jsonResponse(['detail' => 'Invalid API key'])), + ]), Middleware::history($container)); + + $api->purchases->get('123'); + } + + public function testNotFoundException(): void + { + $this->expectException(\Chip\Exception\NotFoundException::class); + $this->expectExceptionMessage('Purchase not found'); + + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(404, [], $this->jsonResponse(['detail' => 'Purchase not found'])), + ]), Middleware::history($container)); + + $api->purchases->get('123'); + } + + public function testValidationException(): void + { + $this->expectException(\Chip\Exception\ValidationException::class); + $this->expectExceptionMessage('Validation failed'); + + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(422, [], $this->jsonResponse(['detail' => 'Validation failed', 'errors' => ['email' => 'Required']])), + ]), Middleware::history($container)); + + $api->purchases->create(new \Chip\Model\Purchase()); + } + + public function testServerException(): void + { + $this->expectException(\Chip\Exception\ServerException::class); + $this->expectExceptionMessage('Internal server error'); + + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(500, [], $this->jsonResponse(['detail' => 'Internal server error'])), + new Response(500, [], $this->jsonResponse(['detail' => 'Internal server error'])), + new Response(500, [], $this->jsonResponse(['detail' => 'Internal server error'])), + new Response(500, [], $this->jsonResponse(['detail' => 'Internal server error'])), + ]), Middleware::history($container)); + + $api->purchases->get('123'); + } + + public function testValidationExceptionExposesErrors(): void + { + try { + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(422, [], $this->jsonResponse(['detail' => 'Validation failed', 'errors' => ['email' => 'Required', 'amount' => 'Must be positive']])), + ]), Middleware::history($container)); + + $api->purchases->create(new \Chip\Model\Purchase()); + $this->fail('Expected ValidationException'); + } catch (\Chip\Exception\ValidationException $e) { + $this->assertEquals(['email' => 'Required', 'amount' => 'Must be positive'], $e->getErrors()); + } + } + + public function testCreateClient(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $client = new \Chip\Model\ClientDetails(); + $client->email = 'test@example.com'; + $api->clients->create($client); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/', $transaction['request']->getUri()->getPath()); + } + + public function testCreateWebhook(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $webhook = new \Chip\Model\Webhook(); + $webhook->title = 'Test Webhook'; + $webhook->callback = 'https://example.com/webhook'; + $api->webhooks->create($webhook); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('webhooks/', $transaction['request']->getUri()->getPath()); + } + + public function testGetWebhook(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->webhooks->get('wh_123'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('webhooks/wh_123/', $transaction['request']->getUri()->getPath()); + } + + public function testMarkAsPaid(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->markAsPaid('123'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/mark_as_paid/', $transaction['request']->getUri()->getPath()); + } + + public function testMarkAsPaidWithTimestamp(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->markAsPaid('123', 1642060235); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/mark_as_paid/', $transaction['request']->getUri()->getPath()); + $body = json_decode($transaction['request']->getBody()->getContents(), true); + $this->assertEquals(1642060235, $body['paid_on']); + } + + public function testPaymentMethodsMapsResponseToModel(): void + { + $responseBody = $this->jsonResponse([ + 'available_payment_methods' => ['card', 'fpx'], + 'by_country' => ['MY' => ['fpx']], + 'country_names' => ['MY' => 'Malaysia'], + 'names' => ['card' => 'Credit Card', 'fpx' => 'FPX'], + 'card_methods' => ['visa', 'mastercard'], + ]); + + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], $responseBody), + ]), Middleware::history($container)); + + $methods = $api->paymentMethods->list('MYR'); + + $this->assertInstanceOf(\Chip\Model\PaymentMethods::class, $methods); + $this->assertEquals(['card', 'fpx'], $methods->available_payment_methods); + $this->assertEquals(['MY' => ['fpx']], $methods->by_country); + $this->assertEquals(['MY' => 'Malaysia'], $methods->country_names); + $this->assertEquals(['card' => 'Credit Card', 'fpx' => 'FPX'], $methods->names); + $this->assertEquals(['visa', 'mastercard'], $methods->card_methods); + } + + public function testWebhookMapsResponseToModel(): void + { + $responseBody = $this->jsonResponse([ + 'id' => 'wh_123', + 'title' => 'Test Webhook', + 'callback' => 'https://example.com/webhook', + 'public_key' => 'abc123', + 'all_events' => true, + 'events' => ['purchase.created', 'purchase.paid'], + ]); + + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], $responseBody), + ]), Middleware::history($container)); + + $webhook = $api->webhooks->get('wh_123'); + + $this->assertInstanceOf(\Chip\Model\Webhook::class, $webhook); + $this->assertEquals('wh_123', $webhook->id); + $this->assertEquals('Test Webhook', $webhook->title); + $this->assertEquals('https://example.com/webhook', $webhook->callback); + $this->assertEquals('abc123', $webhook->public_key); + $this->assertTrue($webhook->all_events); + $this->assertEquals(['purchase.created', 'purchase.paid'], $webhook->events); + } + + public function testClientDetailsMapsResponseToModel(): void + { + $responseBody = $this->jsonResponse([ + 'id' => 'client_123', + 'email' => 'test@example.com', + 'phone' => '+60123456789', + 'full_name' => 'Test User', + 'country' => 'MY', + 'city' => 'Kuala Lumpur', + ]); + + $container = []; + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], $responseBody), + ]), Middleware::history($container)); + + $client = new \Chip\Model\ClientDetails(); + $client->email = 'test@example.com'; + $result = $api->clients->create($client); + + $this->assertInstanceOf(\Chip\Model\ClientDetails::class, $result); + } + + public function testLoggerReceivesDebugAndErrorCalls(): void + { + $logger = $this->createMock(\Psr\Log\LoggerInterface::class); + $logger->expects($this->once()) + ->method('debug') + ->with('CHIP API request', $this->arrayHasKey('method')); + $logger->expects($this->once()) + ->method('error') + ->with('CHIP API client error', $this->arrayHasKey('status')); + + $handlerStack = HandlerStack::create(new MockHandler([ + new Response(401, [], $this->jsonResponse(['detail' => 'Unauthorized'])), + ])); + $api = new \Chip\ChipApi('', '', 'https://gate.chip-in.asia/api/v1/', [ + 'handler' => $handlerStack, + ], $logger); + + try { + $api->purchases->get('123'); + $this->fail('Expected AuthenticationException'); + } catch (\Chip\Exception\AuthenticationException $e) { + // expected + } + } + + public function testTimeoutConfiguration(): void + { + $container = []; + $history = Middleware::history($container); + $handlerStack = HandlerStack::create(new MockHandler([ + new Response(200, [], '{}'), + ])); + $handlerStack->push($history); + + $api = new \Chip\ChipApi('', '', 'https://gate.chip-in.asia/api/v1/', [ + 'handler' => $handlerStack, + 'timeout' => 60, + ]); + + $api->purchases->get('123'); + $transaction = $container[0]; + + $this->assertEquals(60, $transaction['options']['timeout']); + } + + public function testInvalidWebhookVerificationReturnsFalse(): void + { + $content = '{"id": "test"}'; + $signature = 'invalid_signature'; + $publicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArzedRaG/aa191+f3/Syf\nye4lbwaVDngwBpsV/JidZ3T/27oEAPtwZ3oqhmhsBQcVB/f94ecFdj49NTG1DZZN\nfkWjSZEViL22oEGBryK2MjkUrW30kY1Yh0vCa/e0nIG/+9b1TLfzHIwjm54hw1R/\nRi/m/tf1nLMEm06ogDNV/AUyg6uyNLqp21NxKP7+xV6yfPkfX1s+qSjciyCPzO6r\n+TsG3GTqopG1FSaWx+R0+bmsOEmV5YQKMUlLKlf0wJUD7mjsNioFomEp5QBpASbE\nLfNDO13L5FiUgLtWcz+ZazCZmNUdhstLvrEVt8NhvPWBy96YWm4GfXx7xr8F11yH\npQIDAQAB\n-----END PUBLIC KEY-----"; + + $this->assertFalse(\Chip\ChipApi::verify($content, $signature, $publicKey)); + } + + public function testCreateBilling(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $billing = new \Chip\Model\Billing\BillingTemplate(); + $billing->brand_id = 'brand_123'; + $api->billing->create($billing); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing/', $transaction['request']->getUri()->getPath()); + } + + public function testCreateBillingTemplate(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $billing = new \Chip\Model\Billing\BillingTemplate(); + $billing->brand_id = 'brand_123'; + $api->billing->createTemplate($billing); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/', $transaction['request']->getUri()->getPath()); + } + + public function testGetBillingTemplates(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->billing->listTemplates(); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/', $transaction['request']->getUri()->getPath()); + } + + public function testGetBillingTemplate(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->billing->getTemplate('bt_123'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/bt_123/', $transaction['request']->getUri()->getPath()); + } + + public function testUpdateBillingTemplate(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $billing = new \Chip\Model\Billing\BillingTemplate(); + $billing->title = 'Updated'; + $api->billing->updateTemplate('bt_123', $billing); + $transaction = $container[0]; + + $this->assertEquals('PUT', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/bt_123/', $transaction['request']->getUri()->getPath()); + } + + public function testDeleteBillingTemplate(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->billing->deleteTemplate('bt_123'); + $transaction = $container[0]; + + $this->assertEquals('DELETE', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/bt_123/', $transaction['request']->getUri()->getPath()); + } + + public function testSendBillingTemplateInvoice(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $client = new \Chip\Model\Billing\BillingTemplateClient(); + $client->client_id = 'client_123'; + $api->billing->sendInvoice('bt_123', $client); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/bt_123/send_invoice/', $transaction['request']->getUri()->getPath()); + } + + public function testAddBillingTemplateSubscriber(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $client = new \Chip\Model\Billing\BillingTemplateClient(); + $client->client_id = 'client_123'; + $api->billing->addSubscriber('bt_123', $client); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/bt_123/add_subscriber/', $transaction['request']->getUri()->getPath()); + } + + public function testGetBillingTemplateClients(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->billing->listClients('bt_123'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/bt_123/clients/', $transaction['request']->getUri()->getPath()); + } + + public function testGetBillingTemplateClient(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->billing->getClient('bt_123', 'bc_456'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/bt_123/clients/bc_456/', $transaction['request']->getUri()->getPath()); + } + + public function testUpdateBillingTemplateClient(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $client = new \Chip\Model\Billing\BillingTemplateClient(); + $client->status = 'active'; + $api->billing->updateClient('bt_123', 'bc_456', $client); + $transaction = $container[0]; + + $this->assertEquals('PATCH', $transaction['request']->getMethod()); + $this->assertStringContainsString('billing_templates/bt_123/clients/bc_456/', $transaction['request']->getUri()->getPath()); + } + + public function testGetClients(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->clients->list(); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/', $transaction['request']->getUri()->getPath()); + } + + public function testResendInvoice(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + $api->purchases->resendInvoice('123'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('purchases/123/resend_invoice', $transaction['request']->getUri()->getPath()); + } + + public function testGetClient(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->clients->get('client_123'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/client_123/', $transaction['request']->getUri()->getPath()); + } + + public function testUpdateClient(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $client = new \Chip\Model\ClientDetails(); + $client->email = 'updated@example.com'; + $api->clients->update('client_123', $client); + $transaction = $container[0]; + + $this->assertEquals('PUT', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/client_123/', $transaction['request']->getUri()->getPath()); + } + + public function testPartialUpdateClient(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $client = new \Chip\Model\ClientDetails(); + $client->email = 'updated@example.com'; + $api->clients->partialUpdate('client_123', $client); + $transaction = $container[0]; + + $this->assertEquals('PATCH', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/client_123/', $transaction['request']->getUri()->getPath()); + } + + public function testDeleteClient(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(204, [], ''), + ]), $history); + + $api->clients->delete('client_123'); + $transaction = $container[0]; + + $this->assertEquals('DELETE', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/client_123/', $transaction['request']->getUri()->getPath()); + } + + public function testListRecurringTokens(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->clients->listRecurringTokens('client_123'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/client_123/recurring_tokens/', $transaction['request']->getUri()->getPath()); + } + + public function testGetRecurringToken(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->clients->getRecurringToken('client_123', 'purchase_456'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/client_123/recurring_tokens/purchase_456/', $transaction['request']->getUri()->getPath()); + } + + public function testDeleteRecurringTokenByClient(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(204, [], ''), + ]), $history); + + $api->clients->deleteRecurringToken('client_123', 'purchase_456'); + $transaction = $container[0]; + + $this->assertEquals('DELETE', $transaction['request']->getMethod()); + $this->assertStringContainsString('clients/client_123/recurring_tokens/purchase_456/', $transaction['request']->getUri()->getPath()); + } + + public function testListWebhooks(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->webhooks->list(); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('webhooks/', $transaction['request']->getUri()->getPath()); + } + + public function testUpdateWebhook(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $webhook = new \Chip\Model\Webhook(); + $webhook->title = 'Updated'; + $api->webhooks->update('wh_123', $webhook); + $transaction = $container[0]; + + $this->assertEquals('PUT', $transaction['request']->getMethod()); + $this->assertStringContainsString('webhooks/wh_123/', $transaction['request']->getUri()->getPath()); + } + + public function testPartialUpdateWebhook(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $webhook = new \Chip\Model\Webhook(); + $webhook->title = 'Updated'; + $api->webhooks->partialUpdate('wh_123', $webhook); + $transaction = $container[0]; + + $this->assertEquals('PATCH', $transaction['request']->getMethod()); + $this->assertStringContainsString('webhooks/wh_123/', $transaction['request']->getUri()->getPath()); + } + + public function testGetPublicKey(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], $this->jsonResponse(['public_key' => 'pk_test'])), + ]), $history); + + $key = $api->publicKey->get(); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('public_key/', $transaction['request']->getUri()->getPath()); + $this->assertEquals('pk_test', $key); + } + + public function testGetBalance(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], $this->jsonResponse(['MYR' => ['balance' => 100]])), + ]), $history); + + $result = $api->account->balance(['currency' => 'MYR']); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('account/json/balance/', $transaction['request']->getUri()->getPath()); + $this->assertStringContainsString('currency=MYR', $transaction['request']->getUri()->getQuery()); + $this->assertEquals(['MYR' => ['balance' => 100]], $result); + } + + public function testGetTurnover(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], $this->jsonResponse(['incoming' => ['turnover' => 50], 'outgoing' => ['turnover' => 20]])), + ]), $history); + + $result = $api->account->turnover(['currency' => 'MYR', 'from' => 1609459200, 'to' => 1609545600]); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('account/json/turnover/', $transaction['request']->getUri()->getPath()); + $query = $transaction['request']->getUri()->getQuery(); + $this->assertStringContainsString('currency=MYR', $query); + $this->assertStringContainsString('from=1609459200', $query); + $this->assertStringContainsString('to=1609545600', $query); + } + + public function testScheduleStatement(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(201, [], '{}'), + ]), $history); + + $statement = new \Chip\Model\CompanyStatement(); + $statement->format = 'csv'; + $api->statements->schedule($statement); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('company_statements/', $transaction['request']->getUri()->getPath()); + } + + public function testListStatements(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->statements->list(); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('company_statements/', $transaction['request']->getUri()->getPath()); + } + + public function testGetStatement(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->statements->get('stmt_123'); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $this->assertStringContainsString('company_statements/stmt_123/', $transaction['request']->getUri()->getPath()); + } + + public function testCancelStatement(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->statements->cancel('stmt_123'); + $transaction = $container[0]; + + $this->assertEquals('POST', $transaction['request']->getMethod()); + $this->assertStringContainsString('company_statements/stmt_123/cancel', $transaction['request']->getUri()->getPath()); + } + + public function testPaymentMethodsWithOptionalParams(): void + { + $container = []; + $history = Middleware::history($container); + $api = $this->getMockApi(new MockHandler([ + new Response(200, [], '{}'), + ]), $history); + + $api->paymentMethods->list('MYR', ['country' => 'MY', 'recurring' => true, 'amount' => 500]); + $transaction = $container[0]; + + $this->assertEquals('GET', $transaction['request']->getMethod()); + $query = $transaction['request']->getUri()->getQuery(); + $this->assertStringContainsString('country=MY', $query); + $this->assertStringContainsString('recurring=1', $query); + $this->assertStringContainsString('amount=500', $query); + } + + /** + * @param array $data + */ + protected function jsonResponse(array $data): string + { + $result = json_encode($data); + $this->assertIsString($result); + + return $result; + } + + protected function getMockApi(MockHandler $mock, callable $history): \Chip\ChipApi + { + $handlerStack = HandlerStack::create($mock); + $handlerStack->push($history); + + return new \Chip\ChipApi('', '', 'https://gate.chip-in.asia/api/v1/', [ + 'handler' => $handlerStack, + ]); + } +} diff --git a/tests/ExceptionTest.php b/tests/ExceptionTest.php new file mode 100644 index 0000000..491fcb1 --- /dev/null +++ b/tests/ExceptionTest.php @@ -0,0 +1,88 @@ + 'Something went wrong']; + $e = new \Chip\Exception\ChipApiException('Server error', 500, $body); + + $this->assertEquals('Server error', $e->getMessage()); + $this->assertEquals(500, $e->getCode()); + $this->assertEquals($body, $e->getResponseBody()); + } + + public function testChipApiExceptionAcceptsNullResponseBody(): void + { + $e = new \Chip\Exception\ChipApiException('No body'); + + $this->assertNull($e->getResponseBody()); + } + + public function testAuthenticationExceptionExtendsChipApiException(): void + { + $e = new \Chip\Exception\AuthenticationException('Invalid API key', 401, ['detail' => 'Invalid API key']); + + $this->assertInstanceOf(\Chip\Exception\ChipApiException::class, $e); + $this->assertEquals('Invalid API key', $e->getMessage()); + $this->assertEquals(['detail' => 'Invalid API key'], $e->getResponseBody()); + } + + public function testNotFoundExceptionExtendsChipApiException(): void + { + $e = new \Chip\Exception\NotFoundException('Not found', 404); + + $this->assertInstanceOf(\Chip\Exception\ChipApiException::class, $e); + $this->assertEquals('Not found', $e->getMessage()); + } + + public function testValidationExceptionExtractsErrorsFromResponseBody(): void + { + $body = ['detail' => 'Validation failed', 'errors' => ['email' => 'Required']]; + $e = new \Chip\Exception\ValidationException('Validation failed', 422, $body); + + $this->assertInstanceOf(\Chip\Exception\ChipApiException::class, $e); + $this->assertEquals(['email' => 'Required'], $e->getErrors()); + $this->assertEquals($body, $e->getResponseBody()); + } + + public function testValidationExceptionReturnsEmptyErrorsWhenMissing(): void + { + $e = new \Chip\Exception\ValidationException('Validation failed', 422, ['detail' => 'Bad request']); + + $this->assertEquals([], $e->getErrors()); + } + + public function testValidationExceptionReturnsEmptyErrorsWhenNullBody(): void + { + $e = new \Chip\Exception\ValidationException('Validation failed'); + + $this->assertEquals([], $e->getErrors()); + } + + public function testClientExceptionExtendsChipApiException(): void + { + $e = new \Chip\Exception\ClientException('Bad request', 400, ['detail' => 'Bad request']); + + $this->assertInstanceOf(\Chip\Exception\ChipApiException::class, $e); + } + + public function testServerExceptionExtendsChipApiException(): void + { + $e = new \Chip\Exception\ServerException('Internal server error', 500, ['detail' => 'Internal server error']); + + $this->assertInstanceOf(\Chip\Exception\ChipApiException::class, $e); + } + + public function testExceptionPreservesPreviousException(): void + { + $previous = new \RuntimeException('Network failure'); + $e = new \Chip\Exception\ServerException('Server error', 500, null, $previous); + + $this->assertSame($previous, $e->getPrevious()); + } +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php new file mode 100644 index 0000000..1f5660d --- /dev/null +++ b/tests/ModelTest.php @@ -0,0 +1,232 @@ +id = 'p123'; + $purchase->status = 'created'; + $purchase->brand_id = 'brand_456'; + + $json = json_encode($purchase); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertEquals('p123', $decoded['id']); + $this->assertEquals('created', $decoded['status']); + $this->assertEquals('brand_456', $decoded['brand_id']); + $this->assertArrayNotHasKey('reference', $decoded); + } + + public function testPurchaseDetailsSerializesProductsArray(): void + { + $details = new \Chip\Model\PurchaseDetails(); + $details->currency = 'USD'; + $details->total = 500; + $details->language = 'en'; + + $product = new \Chip\Model\Product(); + $product->name = 'Widget'; + $product->price = 500; + $product->quantity = '1.0'; + $details->products = [$product]; + + $json = json_encode($details); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertEquals('USD', $decoded['currency']); + $this->assertEquals(500, $decoded['total']); + $this->assertCount(1, $decoded['products']); + $this->assertEquals('Widget', $decoded['products'][0]['name']); + } + + public function testProductStripsNullValues(): void + { + $product = new \Chip\Model\Product(); + $product->name = 'Gadget'; + $product->price = 100; + $product->quantity = '2.0'; + + $json = json_encode($product); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertArrayHasKey('name', $decoded); + $this->assertArrayHasKey('price', $decoded); + $this->assertArrayNotHasKey('discount', $decoded); + $this->assertArrayNotHasKey('tax_percent', $decoded); + } + + public function testClientDetailsSerializesAndStripsNulls(): void + { + $client = new \Chip\Model\ClientDetails(); + $client->email = 'test@example.com'; + $client->full_name = 'Test User'; + $client->country = 'MY'; + $client->city = 'Kuala Lumpur'; + + $json = json_encode($client); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertEquals('test@example.com', $decoded['email']); + $this->assertEquals('Test User', $decoded['full_name']); + $this->assertArrayNotHasKey('phone', $decoded); + $this->assertArrayNotHasKey('street_address', $decoded); + } + + public function testWebhookSerializesAndStripsNulls(): void + { + $webhook = new \Chip\Model\Webhook(); + $webhook->id = 'wh_123'; + $webhook->title = 'Test Webhook'; + $webhook->callback = 'https://example.com/webhook'; + $webhook->all_events = true; + $webhook->events = ['purchase.created']; + + $json = json_encode($webhook); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertEquals('wh_123', $decoded['id']); + $this->assertEquals('Test Webhook', $decoded['title']); + $this->assertEquals('https://example.com/webhook', $decoded['callback']); + $this->assertTrue($decoded['all_events']); + $this->assertEquals(['purchase.created'], $decoded['events']); + $this->assertArrayNotHasKey('public_key', $decoded); + } + + public function testPaymentMethodsSerializesAndStripsNulls(): void + { + $methods = new \Chip\Model\PaymentMethods(); + $methods->available_payment_methods = ['card', 'fpx']; + $methods->by_country = ['MY' => ['fpx']]; + $methods->country_names = ['MY' => 'Malaysia']; + $methods->names = ['card' => 'Credit Card']; + $methods->card_methods = ['visa', 'mastercard']; + + $json = json_encode($methods); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertEquals(['card', 'fpx'], $decoded['available_payment_methods']); + $this->assertEquals(['MY' => ['fpx']], $decoded['by_country']); + $this->assertArrayNotHasKey('logos', $decoded); + } + + public function testBillingTemplateStripsNulls(): void + { + $template = new \Chip\Model\Billing\BillingTemplate(); + $template->brand_id = 'brand_123'; + $template->title = 'Monthly Plan'; + $template->is_subscription = true; + + $json = json_encode($template); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertEquals('brand_123', $decoded['brand_id']); + $this->assertEquals('Monthly Plan', $decoded['title']); + $this->assertTrue($decoded['is_subscription']); + $this->assertArrayNotHasKey('invoice_issued', $decoded); + } + + public function testBillingTemplateClientStripsNulls(): void + { + $client = new \Chip\Model\Billing\BillingTemplateClient(); + $client->client_id = 'client_123'; + $client->status = 'active'; + + $json = json_encode($client); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertEquals('client_123', $decoded['client_id']); + $this->assertEquals('active', $decoded['status']); + $this->assertArrayNotHasKey('payment_method_whitelist', $decoded); + } + + public function testBillingTemplateClientAddSubscriberStripsNulls(): void + { + $subscriber = new \Chip\Model\Billing\BillingTemplateClientAddSubscriber(); + + $json = json_encode($subscriber); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + } + + public function testBillingTemplateClientListStripsNulls(): void + { + $list = new \Chip\Model\Billing\BillingTemplateClientList(); + $list->results = []; + + $json = json_encode($list); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + // empty array is filtered out by array_filter + $this->assertArrayNotHasKey('results', $decoded); + $this->assertArrayNotHasKey('next', $decoded); + } + + public function testBillingTemplateListStripsNulls(): void + { + $list = new \Chip\Model\Billing\BillingTemplateList(); + $list->results = []; + + $json = json_encode($list); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + // empty array is filtered out by array_filter + $this->assertArrayNotHasKey('results', $decoded); + $this->assertArrayNotHasKey('previous', $decoded); + } + + public function testEmptyModelSerializesToEmptyObject(): void + { + $purchase = new \Chip\Model\Purchase(); + + $json = json_encode($purchase); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertIsArray($decoded); + $this->assertEmpty($decoded); + } + + public function testNestedModelSerialization(): void + { + $purchase = new \Chip\Model\Purchase(); + $purchase->brand_id = 'brand_123'; + + $details = new \Chip\Model\PurchaseDetails(); + $details->currency = 'EUR'; + $details->total = 100; + + $product = new \Chip\Model\Product(); + $product->name = 'Test'; + $product->price = 100; + $product->quantity = '1.0'; + $details->products = [$product]; + + $purchase->purchase = $details; + + $json = json_encode($purchase); + $this->assertIsString($json); + $decoded = json_decode($json, true); + + $this->assertEquals('brand_123', $decoded['brand_id']); + $this->assertEquals('EUR', $decoded['purchase']['currency']); + $this->assertCount(1, $decoded['purchase']['products']); + $this->assertEquals('Test', $decoded['purchase']['products'][0]['name']); + } +} diff --git a/tests/PurchaseBuilderTest.php b/tests/PurchaseBuilderTest.php new file mode 100644 index 0000000..dcdc8fe --- /dev/null +++ b/tests/PurchaseBuilderTest.php @@ -0,0 +1,51 @@ +brandId('brand_123') + ->currency('USD') + ->language('en') + ->successRedirect('https://example.com/success') + ->failureRedirect('https://example.com/failure') + ->successCallback('https://example.com/callback') + ->clientEmail('test@example.com') + ->clientFullName('Test User') + ->addProduct('Widget', 5000, 2) + ->addProduct('Gadget', 3000) + ->build(); + + $this->assertInstanceOf(\Chip\Model\Purchase::class, $purchase); + $this->assertEquals('brand_123', $purchase->brand_id); + $this->assertEquals('USD', $purchase->purchase->currency); + $this->assertEquals('en', $purchase->purchase->language); + $this->assertEquals('https://example.com/success', $purchase->success_redirect); + $this->assertEquals('https://example.com/failure', $purchase->failure_redirect); + $this->assertEquals('https://example.com/callback', $purchase->success_callback); + + $this->assertInstanceOf(\Chip\Model\ClientDetails::class, $purchase->client); + $this->assertEquals('test@example.com', $purchase->client->email); + $this->assertEquals('Test User', $purchase->client->full_name); + + $this->assertCount(2, $purchase->purchase->products); + $this->assertEquals('Widget', $purchase->purchase->products[0]->name); + $this->assertEquals(5000, $purchase->purchase->products[0]->price); + $this->assertEquals(2.0, $purchase->purchase->products[0]->quantity); + $this->assertEquals('Gadget', $purchase->purchase->products[1]->name); + $this->assertEquals(3000, $purchase->purchase->products[1]->price); + $this->assertEquals(1.0, $purchase->purchase->products[1]->quantity); + } + + public function testProductsArrayDefaultsToEmpty(): void + { + $purchase = \Chip\Builder\PurchaseBuilder::create() + ->brandId('brand_123') + ->build(); + + $this->assertEmpty($purchase->purchase->products); + } +}