diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e0dcf2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + uses: ./.github/workflows/test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..beb9ac5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + + # ── Gate ──────────────────────────────────────────────────────────────────── + # The release job will not run until every matrix leg of `test` passes. + # If any PHP version fails, the release is blocked. + test: + uses: ./.github/workflows/test.yml + + # ── Release ───────────────────────────────────────────────────────────────── + release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: test # hard gate — every PHP matrix leg must be green + + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + # ── Determine version type from the tag ───────────────────────────────── + # + # Supported tag patterns: + # v1.0.0 → stable, classified as major / minor / patch + # v1.0.0-alpha.1 → alpha (pre-release) + # v1.0.0-beta.2 → beta (pre-release) + # v1.0.0-rc.1 → release candidate (pre-release) + # v1.0.0-anything → generic pre-release + # + - name: Determine version type + id: version + run: | + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + + if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-(.+))?$ ]]; then + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[3]}" + PRE_ID="${BASH_REMATCH[5]}" + + if [[ -n "$PRE_ID" ]]; then + # ── Pre-release ──────────────────────────────────────────────── + PRERELEASE=true + if [[ "$PRE_ID" =~ ^alpha ]]; then TYPE="alpha"; LABEL="Alpha"; + elif [[ "$PRE_ID" =~ ^beta ]]; then TYPE="beta"; LABEL="Beta"; + elif [[ "$PRE_ID" =~ ^rc ]]; then TYPE="rc"; LABEL="Release Candidate"; + else TYPE="pre"; LABEL="Pre-release"; + fi + else + # ── Stable release ───────────────────────────────────────────── + PRERELEASE=false + if [[ "$MAJOR" == "0" ]]; then TYPE="prestable"; LABEL="0.x Release"; + elif [[ "$MINOR" == "0" && "$PATCH" == "0" ]]; then TYPE="major"; LABEL="Major Release"; + elif [[ "$PATCH" == "0" ]]; then TYPE="minor"; LABEL="Minor Release"; + else TYPE="patch"; LABEL="Patch"; + fi + fi + else + # Fallback for non-semver tags + TYPE="release"; LABEL="Release"; PRERELEASE=false + fi + + echo "type=$TYPE" >> $GITHUB_OUTPUT + echo "label=$LABEL" >> $GITHUB_OUTPUT + echo "prerelease=$PRERELEASE" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + + echo "### Version info" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| Tag | \`$TAG\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`$VERSION\` |" >> $GITHUB_STEP_SUMMARY + echo "| Type | \`$TYPE\` |" >> $GITHUB_STEP_SUMMARY + echo "| Label | $LABEL |" >> $GITHUB_STEP_SUMMARY + echo "| Pre-release | $PRERELEASE |" >> $GITHUB_STEP_SUMMARY + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: "${{ github.ref_name }} — ${{ steps.version.outputs.label }}" + prerelease: ${{ steps.version.outputs.prerelease }} + generate_release_notes: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a132882 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,76 @@ +name: Tests + +on: + workflow_call: + +jobs: + + # ── Unit tests + static analysis ───────────────────────────────────────────── + # The whole suite is mock-based (Mockery/Pest) — no external services needed. + # Floor is PHP 8.2 here because the dev toolchain (pestphp/pest ^3) requires it; + # 8.4 guards against new deprecations. Runtime support for 8.1 is verified + # separately by the `floor` job below. + unit: + name: Unit — PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json, curl, mbstring + coverage: none + + # composer.lock is not committed (this is a library), so the cache key is + # derived from composer.json instead. + - name: Cache Composer packages + uses: actions/cache@v5 + with: + path: vendor + key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: php-${{ matrix.php }}-composer- + + - name: Validate composer.json + run: composer validate --no-check-publish --no-check-lock + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G + + - name: Run unit tests + run: vendor/bin/pest --colors=always + + # ── PHP 8.1 runtime floor ──────────────────────────────────────────────────── + # The library supports PHP 8.1, but the dev toolchain (Pest 3) requires 8.2, so + # the full suite can't run there. `composer install` is no help either: even with + # --no-dev, Composer still *resolves* require-dev platform constraints, and Pest's + # `php ^8.2` blocks resolution on 8.1. A syntax lint needs no dependencies, so + # this leg parses every source file on PHP 8.1 — catching any 8.2-only syntax and + # backing the `"php": "^8.1"` claim in composer.json. + floor: + name: Floor — PHP 8.1 (lint) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup PHP 8.1 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Lint sources on PHP 8.1 + run: find src -name '*.php' -print0 | xargs -0 -n1 -P4 php -l diff --git a/README.md b/README.md index c1710a4..3d97da3 100644 --- a/README.md +++ b/README.md @@ -1 +1,325 @@ -# paystack-php \ No newline at end of file +# paystack-php + +A modern, type-safe PHP SDK for [Paystack](https://paystack.com) payment processing. + +## Requirements + +- PHP 8.0+ +- Guzzle 7.x + +## Installation + +```bash +composer require faridibin/paystack-php +``` + +## Getting Started + +The `Paystack` client only registers the `health` service by default. Register the services you need before calling them. + +```php +use Faridibin\Paystack\Paystack; +use Faridibin\Paystack\Services\Payments\Transactions\Transactions; +use Faridibin\Paystack\Services\Payments\Customers; +use Faridibin\Paystack\Contracts\Services\Payments\Transactions\TransactionsInterface; +use Faridibin\Paystack\Contracts\Services\Payments\CustomersInterface; + +$paystack = new Paystack(secretKey: 'sk_live_...'); + +$paystack->registerServices([ + 'transactions' => [Transactions::class, TransactionsInterface::class], + 'customers' => [Customers::class, CustomersInterface::class], +]); + +$paystack->transactions()->initialize(amount: 50000, email: 'customer@example.com'); +$paystack->customers()->create(['email' => 'customer@example.com', 'first_name' => 'John', 'last_name' => 'Doe']); +``` + +The alias on the left of each entry is the dynamic method name you'll call on `$paystack`. Pick whatever names you like. + +## Services + +### Payments + +| Suggested alias | Service class | +|---|---| +| `transactions()` | `Services\Payments\Transactions\Transactions` | +| `splits()` | `Services\Payments\Transactions\Splits` | +| `customers()` | `Services\Payments\Customers` | +| `charge()` | `Services\Payments\Charge` | +| `bulkCharges()` | `Services\Payments\BulkCharges` | +| `refunds()` | `Services\Payments\Refunds` | +| `subaccounts()` | `Services\Payments\Subaccounts` | +| `disputes()` | `Services\Payments\Disputes` | +| `settlements()` | `Services\Payments\Settlements` | +| `paymentRequests()` | `Services\Payments\PaymentRequests` | +| `dedicatedAccount()` | `Services\Payments\DedicatedAccount` | +| `terminal()` | `Services\Payments\Terminal` | +| `applePay()` | `Services\Payments\ApplePay` | + +### Transfers + +| Suggested alias | Service class | +|---|---| +| `transfers()` | `Services\Transfers\Transfers` | +| `recipients()` | `Services\Transfers\Recipients` | +| `control()` | `Services\Transfers\Control` | + +### Recurring + +| Suggested alias | Service class | +|---|---| +| `plans()` | `Services\Recurring\Plans` | +| `subscriptions()` | `Services\Recurring\Subscriptions` | + +### Commerce + +| Suggested alias | Service class | +|---|---| +| `products()` | `Services\Commerce\Products` | +| `paymentPages()` | `Services\Commerce\PaymentPages` | + +### Other + +| Suggested alias | Service class | +|---|---| +| `integration()` | `Services\Integration` | +| `verification()` | `Services\Verification` | +| `miscellaneous()` | `Services\Miscellaneous` | +| `balance()` | `Services\Balance` | +| `directDebit()` | `Services\DirectDebit` | +| `virtualTerminal()` | `Services\VirtualTerminal` | +| `storefront()` | `Services\Storefront` | +| `order()` | `Services\Order` | + +## Examples + +### Transactions + +```php +// Initialize a transaction +$response = $paystack->transactions()->initialize(amount: 50000, email: 'customer@example.com'); +$authUrl = $response->getData()->authorization_url; + +// Verify a transaction +$paystack->transactions()->verify('ref_abc123'); + +// List transactions +$paystack->transactions()->list(perPage: 20, page: 1); + +// Fetch a transaction by id +$paystack->transactions()->fetch('123456789'); + +// Charge a saved authorization +$paystack->transactions()->chargeAuthorization('AUTH_xxx', amount: 10000, email: 'customer@example.com'); + +// Partial debit +$paystack->transactions()->partialDebit('AUTH_xxx', amount: 10000, currency: 'NGN', email: 'customer@example.com'); +``` + +### Customers + +```php +// Create a customer +$paystack->customers()->create([ + 'email' => 'john@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'phone' => '+2348000000000', +]); + +// Fetch a customer +$paystack->customers()->fetch('CUS_xxx'); + +// Update a customer +$paystack->customers()->update('CUS_xxx', ['first_name' => 'Jane']); + +// Validate a customer's identity (e.g. BVN check) +$paystack->customers()->validate('CUS_xxx', [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'type' => 'bank_account', + 'value' => '0123456789', + 'country' => 'NG', + 'bvn' => '12345678901', + 'bank_code' => '058', + 'account_number' => '0123456789', +]); + +// Whitelist or blacklist a customer +$paystack->customers()->setRiskStatus('CUS_xxx', 'allow'); +``` + +### Transfers + +```php +use Faridibin\Paystack\Enums\Currency; + +// Initiate a transfer (reference must be unique per request — required for idempotency) +$paystack->transfers()->initiateTransfer( + amount: 10000, + recipient: 'RCP_xxx', + reference: 'pay_invoice_42_2026_04', + optional: ['reason' => 'Payment for services'], +); + +// Finalize a transfer (when OTP is required) +$paystack->transfers()->finalizeTransfer('TRF_xxx', '123456'); + +// Bulk transfers +$paystack->transfers()->initiateBulkTransfer(Currency::NGN, [ + ['amount' => 5000, 'recipient' => 'RCP_aaa', 'reference' => 'inv_1', 'reason' => 'Invoice 1'], + ['amount' => 7500, 'recipient' => 'RCP_bbb', 'reference' => 'inv_2', 'reason' => 'Invoice 2'], +]); + +// Verify and fetch +$paystack->transfers()->verifyTransfer('ref_abc123'); +$paystack->transfers()->fetchTransfer('TRF_xxx'); +``` + +### Recipients + +```php +use Faridibin\Paystack\Enums\RecipientType; + +// Create a single recipient +$paystack->recipients()->createRecipient( + type: RecipientType::NUBAN, + name: 'John Doe', + accountNumber: '0123456789', + bankCode: '058', +); + +// Bulk recipients +$paystack->recipients()->createBulkRecipients([ + ['type' => 'nuban', 'name' => 'John Doe', 'account_number' => '0123456789', 'bank_code' => '058'], + ['type' => 'nuban', 'name' => 'Jane Smith','account_number' => '0987654321', 'bank_code' => '058'], +]); +``` + +### Plans & Subscriptions + +```php +use Faridibin\Paystack\Enums\PlanInterval; + +// Create a plan +$paystack->plans()->create('Pro Monthly', amount: 500000, interval: PlanInterval::MONTHLY); + +// Subscribe a customer +$paystack->subscriptions()->create('CUS_xxx', 'PLN_xxx'); + +// Enable / disable a subscription (token is the email_token from the subscription object) +$paystack->subscriptions()->toggle('SUB_xxx', 'tok_xxx', active: true); +$paystack->subscriptions()->toggle('SUB_xxx', 'tok_xxx', active: false); + +// Generate or email a card-update link for the subscription +$paystack->subscriptions()->generateUpdateSubscriptionLink('SUB_xxx'); +$paystack->subscriptions()->sendUpdateSubscriptionLink('SUB_xxx'); +``` + +### Splits + +```php +use Faridibin\Paystack\Enums\SplitType; +use Faridibin\Paystack\Enums\Bearer; +use Faridibin\Paystack\Enums\Currency; + +$paystack->splits()->createSplit( + name: 'Halfsies', + type: SplitType::PERCENTAGE, + currency: Currency::NGN, + subaccounts: [['subaccount' => 'ACCT_xxx', 'share' => 50]], + bearer: Bearer::SUB_ACCOUNT, + bearerSubaccount: 'ACCT_xxx', +); +``` + +### Storefront + +```php +$paystack->storefront()->create([ + 'name' => 'My Store', + 'slug' => 'my-store', + 'description' => 'Curated goods', + 'currency' => 'NGN', +]); + +$paystack->storefront()->verifySlug('my-store'); +$paystack->storefront()->fetch('1'); +$paystack->storefront()->update('1', ['name' => 'My New Store']); +$paystack->storefront()->addProducts('1', ['products' => [10, 20]]); +$paystack->storefront()->publish('1'); +$paystack->storefront()->duplicate('1'); +$paystack->storefront()->fetchOrders('1'); +$paystack->storefront()->listProducts('1'); +$paystack->storefront()->delete('1'); +``` + +### Orders + +```php +$paystack->order()->create([ + 'amount' => 50000, + 'currency' => 'NGN', + 'items' => [['product' => 1, 'quantity' => 2]], +]); + +$paystack->order()->list(perPage: 20, page: 1); +$paystack->order()->fetch('ORD_xxx'); +$paystack->order()->fetchByProduct('123'); +$paystack->order()->validate('ORD_xxx'); +``` + +### Miscellaneous + +```php +$paystack->miscellaneous()->listCountries(); +$paystack->miscellaneous()->listStates('CA'); +$paystack->miscellaneous()->listBanks('nigeria'); +``` + +## Webhook Verification + +Use `Webhook::validateSignature()` and `Webhook::isIpWhitelisted()` to authenticate incoming Paystack webhooks: + +```php +use Faridibin\Paystack\Webhook; +use Faridibin\Paystack\Exceptions\PaystackException; + +$payload = file_get_contents('php://input'); +$signature = $_SERVER['HTTP_X_PAYSTACK_SIGNATURE'] ?? ''; +$ip = $_SERVER['REMOTE_ADDR'] ?? ''; + +try { + Webhook::isIpWhitelisted($ip); + Webhook::validateSignature($payload, $signature, 'sk_live_...'); + + $event = json_decode($payload, true); + + match ($event['event']) { + 'charge.success' => handleChargeSuccess($event['data']), + 'transfer.success' => handleTransferSuccess($event['data']), + default => null, + }; + + http_response_code(200); +} catch (PaystackException $e) { + http_response_code(403); +} +``` + +Paystack sends webhooks only from these IP addresses: + +- `52.31.139.75` +- `52.49.173.169` +- `52.214.14.220` + +## Testing + +```bash +composer test +``` + +## License + +MIT diff --git a/composer.json b/composer.json index eb8023a..7fcd3d0 100644 --- a/composer.json +++ b/composer.json @@ -21,28 +21,31 @@ } ], "require": { - "php": "^8.0", + "php": "^8.1", "guzzlehttp/guzzle": "^7.0", "nesbot/carbon": "^3.8.4", "ext-json": "*" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", - "mockery/mockery": "^1.5", + "mockery/mockery": "^1.6", + "pestphp/pest": "^3.0", "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^9.0", "symfony/var-dumper": "^7.2", "vlucas/phpdotenv": "^5.6" }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { - "test": "vendor/bin/phpunit", - "test-coverage": "vendor/bin/phpunit --coverage-html coverage", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes", - "analyse": "vendor/bin/phpstan analyse" - }, - "config": { - "sort-packages": true + "analyse": "vendor/bin/phpstan analyse --memory-limit=1G" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..5bd412e --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,16 @@ +parameters: + level: 5 + paths: + - src + + ignoreErrors: + # DTOs initialise readonly properties (metadata, relationships) and the + # Health response via a private trait method called from the constructor. + # This is valid at runtime — readonly props may be written once from within + # class scope — but PHPStan only recognises direct constructor assignment. + - '#Readonly property .* is assigned outside of the constructor\.#' + + # DTO constructors keep a `...$args` variadic as a deliberate safety net so + # that undocumented or future Paystack response fields are absorbed instead + # of throwing. It is intentionally unused. + - '#Constructor of class .* has an unused parameter \$args\.#' diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a6d6e32 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests/Unit + + + + + ./src + + + diff --git a/src/Client.php b/src/Client.php index 67c03ad..71acda5 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,6 +9,13 @@ use GuzzleHttp\Client as GuzzleHttpClient; use GuzzleHttp\Exception\{ClientException, ServerException, GuzzleException}; +/** + * HTTP client for communicating with the Paystack REST API. + * + * Wraps GuzzleHttp and translates HTTP errors into typed Paystack exceptions. + * + * @see \Faridibin\Paystack\Contracts\ClientInterface + */ final class Client implements ClientInterface { /** diff --git a/src/Contracts/ClientInterface.php b/src/Contracts/ClientInterface.php index 277e02c..fc76556 100644 --- a/src/Contracts/ClientInterface.php +++ b/src/Contracts/ClientInterface.php @@ -4,6 +4,9 @@ namespace Faridibin\Paystack\Contracts; +/** + * Defines the contract for the Paystack HTTP client. + */ interface ClientInterface { /** @@ -19,6 +22,8 @@ public function send(string $method, string $endpoint, array $options = []): mix /** * Get the secret key used for API authentication + * + * @return string */ public function getSecretKey(): string; } diff --git a/src/Contracts/DataTransferObjects/DataTransferObject.php b/src/Contracts/DataTransferObjects/DataTransferObject.php index a67745e..367e43b 100644 --- a/src/Contracts/DataTransferObjects/DataTransferObject.php +++ b/src/Contracts/DataTransferObjects/DataTransferObject.php @@ -4,6 +4,11 @@ namespace Faridibin\Paystack\Contracts\DataTransferObjects; +/** + * Base contract for all Paystack Data Transfer Objects. + * + * All DTOs must implement this interface to be serializable as arrays. + */ interface DataTransferObject { /** diff --git a/src/Contracts/HealthInterface.php b/src/Contracts/HealthInterface.php index e89abbb..0db45e6 100644 --- a/src/Contracts/HealthInterface.php +++ b/src/Contracts/HealthInterface.php @@ -8,6 +8,9 @@ use Faridibin\Paystack\DataTransferObjects\Response; use Faridibin\Paystack\Enums\Health; +/** + * Defines the contract for the Paystack platform health service. + */ interface HealthInterface extends ServiceInterface { /** @@ -39,7 +42,7 @@ public function serviceStatuses(): array; /** * Get the health status of Paystack services. * - * @return string + * @return Health */ public function healthy(): Health; diff --git a/src/Contracts/PaystackInterface.php b/src/Contracts/PaystackInterface.php index 98d540e..c698b09 100644 --- a/src/Contracts/PaystackInterface.php +++ b/src/Contracts/PaystackInterface.php @@ -4,32 +4,26 @@ namespace Faridibin\Paystack\Contracts; -use Faridibin\Paystack\Contracts\Services\{ - VerificationInterface, - PaymentsInterface, - TerminalInterface, - TransfersInterface -}; - +/** + * Defines the contract for the main Paystack SDK entry point. + */ interface PaystackInterface { - // /** - // * Get the payment service - // */ - // public function payments(): PaymentsInterface; - - // /** - // * Get the terminal service - // */ - // public function terminal(): TerminalInterface; - - // /** - // * Get the transfers service - // */ - // public function transfers(): TransfersInterface; + /** + * Register a new service with the SDK. + * + * @param string $name The service alias (e.g. 'transactions') + * @param string $serviceClass The fully-qualified service class name + * @param string $interfaceClass The fully-qualified interface class name + * @return static + */ + public function registerService(string $name, string $serviceClass, string $interfaceClass): static; - // /** - // * Get the identity verification service - // */ - // public function identity(): VerificationInterface; + /** + * Register multiple services at once. + * + * @param array $services Map of alias => [serviceClass, interfaceClass] + * @return static + */ + public function registerServices(array $services): static; } diff --git a/src/Contracts/Services/BalanceInterface.php b/src/Contracts/Services/BalanceInterface.php new file mode 100644 index 0000000..8dcca04 --- /dev/null +++ b/src/Contracts/Services/BalanceInterface.php @@ -0,0 +1,20 @@ + $products - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function addProduct(string|int $id, array $products): Response; } diff --git a/src/Contracts/Services/Commerce/ProductsInterface.php b/src/Contracts/Services/Commerce/ProductsInterface.php index b3bc21b..2c127b3 100644 --- a/src/Contracts/Services/Commerce/ProductsInterface.php +++ b/src/Contracts/Services/Commerce/ProductsInterface.php @@ -7,6 +7,9 @@ use Faridibin\Paystack\DataTransferObjects\Response; use Faridibin\Paystack\Enums\Currency; +/** + * Defines the contract for product management operations. + */ interface ProductsInterface extends CommerceInterface { /** @@ -18,7 +21,7 @@ interface ProductsInterface extends CommerceInterface * @param int $price * @param Currency|string $currency * @param array $optional - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function createProduct(string $name, string $description, int $price, Currency|string $currency, array $optional = []): Response; @@ -27,7 +30,7 @@ public function createProduct(string $name, string $description, int $price, Cur * Get details of a product on your integration * * @param string $id - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function fetchProduct(string $id): Response; @@ -37,7 +40,7 @@ public function fetchProduct(string $id): Response; * * @param string $id * @param array $data - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function updateProduct(string $id, array $data): Response; @@ -45,7 +48,16 @@ public function updateProduct(string $id, array $data): Response; * List Products. * List products available on your integration. * - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function listProducts(int $perPage = 50, int $page = 1, array $optional = []): Response; + + /** + * Delete a product. + * Delete a previously created product on your integration + * + * @param string $id + * @return Response + */ + public function deleteProduct(string $id): Response; } diff --git a/src/Contracts/Services/DirectDebitInterface.php b/src/Contracts/Services/DirectDebitInterface.php new file mode 100644 index 0000000..f0ac87e --- /dev/null +++ b/src/Contracts/Services/DirectDebitInterface.php @@ -0,0 +1,31 @@ +items = ($dtoClass && class_exists($dtoClass, true)) ? array_map( fn($item) => new $dtoClass(...$item), - $items ?? [] + $items ) : $items; } diff --git a/src/DataTransferObjects/Generic.php b/src/DataTransferObjects/Generic.php index 9291937..47de330 100644 --- a/src/DataTransferObjects/Generic.php +++ b/src/DataTransferObjects/Generic.php @@ -7,6 +7,12 @@ use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; use Faridibin\Paystack\Traits\MapToArray; +/** + * A generic, dynamic Data Transfer Object for untyped API response data. + * + * Stores arbitrary key-value pairs and exposes them via magic property access. + * Nested arrays are recursively wrapped in Generic instances. + */ class Generic implements DataTransferObject { use MapToArray; diff --git a/src/DataTransferObjects/Health/HealthStatusDTO.php b/src/DataTransferObjects/Health/HealthStatusDTO.php index cc9425b..eebbfe7 100644 --- a/src/DataTransferObjects/Health/HealthStatusDTO.php +++ b/src/DataTransferObjects/Health/HealthStatusDTO.php @@ -7,6 +7,12 @@ use DateTime; use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; +/** + * Data Transfer Object representing the operational status of a single Paystack service. + * + * Provides the service name, operational flag, status string, description, + * and the time the status was last updated. + */ class HealthStatusDTO implements DataTransferObject { /** diff --git a/src/DataTransferObjects/Health/HealthSummaryDTO.php b/src/DataTransferObjects/Health/HealthSummaryDTO.php index 3c1693f..5ab4598 100644 --- a/src/DataTransferObjects/Health/HealthSummaryDTO.php +++ b/src/DataTransferObjects/Health/HealthSummaryDTO.php @@ -8,6 +8,12 @@ use DateTimeZone; use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; +/** + * Data Transfer Object representing the overall Paystack platform health summary. + * + * Aggregates the page metadata and indicator status reported by the + * Paystack Atlassian Statuspage API. + */ class HealthSummaryDTO implements DataTransferObject { /** @@ -52,8 +58,9 @@ public function __construct( ) { $this->name = $page['name'] ?? 'Paystack'; $this->updated_at = new DateTime($page['updated_at'] ?? 'now', new DateTimeZone($page['time_zone'] ?? 'Africa/Lagos')); - $this->status = $status['description'] === 'All Systems Operational'; - $this->indicator = $status['indicator']; + $this->status = ($status['description'] ?? null) === 'All Systems Operational'; + $this->description = $status['description'] ?? null; + $this->indicator = $status['indicator'] ?? ''; } public function toArray(): array diff --git a/src/DataTransferObjects/Integration/TimeoutDTO.php b/src/DataTransferObjects/Integration/TimeoutDTO.php index 080c40a..3b377d7 100644 --- a/src/DataTransferObjects/Integration/TimeoutDTO.php +++ b/src/DataTransferObjects/Integration/TimeoutDTO.php @@ -6,6 +6,11 @@ use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; +/** + * Data Transfer Object representing a Paystack integration payment session timeout. + * + * Holds the configured payment session timeout value in seconds. + */ class TimeoutDTO implements DataTransferObject { /** diff --git a/src/DataTransferObjects/Miscellaneous/BankDTO.php b/src/DataTransferObjects/Miscellaneous/BankDTO.php index cd03749..906ff4f 100644 --- a/src/DataTransferObjects/Miscellaneous/BankDTO.php +++ b/src/DataTransferObjects/Miscellaneous/BankDTO.php @@ -10,6 +10,9 @@ use Faridibin\Paystack\Enums\BankType; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a bank supported by Paystack. + */ class BankDTO implements DataTransferObject { use MapToArray; diff --git a/src/DataTransferObjects/Miscellaneous/CountryDTO.php b/src/DataTransferObjects/Miscellaneous/CountryDTO.php index 4ff84a3..2041609 100644 --- a/src/DataTransferObjects/Miscellaneous/CountryDTO.php +++ b/src/DataTransferObjects/Miscellaneous/CountryDTO.php @@ -8,6 +8,11 @@ use Faridibin\Paystack\Traits\HasRelationships; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a country supported by Paystack. + * + * Includes country metadata, integration defaults, and relationship data. + */ class CountryDTO implements DataTransferObject { use MapToArray, HasRelationships; diff --git a/src/DataTransferObjects/Miscellaneous/Logs/HistoryDTO.php b/src/DataTransferObjects/Miscellaneous/Logs/HistoryDTO.php index 705ce89..3fe950e 100644 --- a/src/DataTransferObjects/Miscellaneous/Logs/HistoryDTO.php +++ b/src/DataTransferObjects/Miscellaneous/Logs/HistoryDTO.php @@ -7,6 +7,11 @@ use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a single entry in a Paystack transaction log history. + * + * Captures the type, message, and timestamp of each step in the transaction flow. + */ class HistoryDTO implements DataTransferObject { use MapToArray; diff --git a/src/DataTransferObjects/Miscellaneous/Logs/LogDTO.php b/src/DataTransferObjects/Miscellaneous/Logs/LogDTO.php index 9394137..eb18152 100644 --- a/src/DataTransferObjects/Miscellaneous/Logs/LogDTO.php +++ b/src/DataTransferObjects/Miscellaneous/Logs/LogDTO.php @@ -9,6 +9,12 @@ use Faridibin\Paystack\DataTransferObjects\Collection; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing the transaction log from a Paystack transaction. + * + * Captures timing, authentication, attempt counts, and a full history of + * steps taken during transaction processing. + */ class LogDTO implements DataTransferObject { use MapToArray; @@ -52,15 +58,13 @@ public function __construct( DateTime|string|int|null $start_time = null, mixed $history = null ) { - if ($start_time) { - if (is_int($start_time)) { - $start_time = date('Y-m-d H:i:s', $start_time); - } - - $this->start_time = !($start_time instanceof DateTime) ? new DateTime($start_time) : $start_time; + if ($start_time && \is_int($start_time)) { + $start_time = date('Y-m-d H:i:s', $start_time); } - $this->history = is_array($history) ? new Collection($history, HistoryDTO::class) : $history; + $this->start_time = $start_time ? ($start_time instanceof DateTime ? $start_time : new DateTime($start_time)) : null; + + $this->history = \is_array($history) ? new Collection($history, HistoryDTO::class) : $history; } /** diff --git a/src/DataTransferObjects/Miscellaneous/StateDTO.php b/src/DataTransferObjects/Miscellaneous/StateDTO.php index 518be45..ef78090 100644 --- a/src/DataTransferObjects/Miscellaneous/StateDTO.php +++ b/src/DataTransferObjects/Miscellaneous/StateDTO.php @@ -7,6 +7,11 @@ use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a state or region supported by Paystack. + * + * Used for address verification (AVS) within a given country. + */ class StateDTO implements DataTransferObject { use MapToArray; diff --git a/src/DataTransferObjects/Payments/AuthorizationDTO.php b/src/DataTransferObjects/Payments/AuthorizationDTO.php index dca6fd9..d9ef65b 100644 --- a/src/DataTransferObjects/Payments/AuthorizationDTO.php +++ b/src/DataTransferObjects/Payments/AuthorizationDTO.php @@ -9,6 +9,12 @@ use Faridibin\Paystack\Enums\Month; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a Paystack payment authorization. + * + * Encapsulates card or bank authorization details including channel, expiry, + * and reusability status. + */ class AuthorizationDTO implements DataTransferObject { use MapToArray; @@ -18,14 +24,14 @@ class AuthorizationDTO implements DataTransferObject * * @var Channels $channel */ - public readonly Channels $channel; + public readonly ?Channels $channel; /** * The expiry month of the authorization * * @var Month $exp_month */ - public readonly Month $exp_month; + public readonly ?Month $exp_month; /** * The reusable status of the authorization @@ -70,24 +76,16 @@ public function __construct( bool|int|null $reusable = null, Month|string|int|null $exp_month = null, Channels|string|null $channel = null, - ...$args // TODO: Remove this line + ...$args ) { - if ($channel && !($channel instanceof Channels)) { - $this->channel = Channels::from($channel); - } + $this->channel = $channel instanceof Channels + ? $channel + : (is_string($channel) && $channel !== '' ? Channels::from($channel) : null); - if ($exp_month && !($exp_month instanceof Month)) { - $this->exp_month = Month::fromValue($exp_month); - } + $this->exp_month = $exp_month instanceof Month + ? $exp_month + : ($exp_month !== null && $exp_month !== '' ? Month::fromValue($exp_month) : null); - if (!is_null($reusable)) { - $this->reusable = (bool) $reusable; - } - - if (!empty($args)) { - dump([ - 'authorization_args' => $args, // TODO: Remove this line - ]); - } + $this->reusable = $reusable !== null ? (bool) $reusable : null; } } diff --git a/src/DataTransferObjects/Payments/CustomerDTO.php b/src/DataTransferObjects/Payments/CustomerDTO.php index e510ded..ecb3029 100644 --- a/src/DataTransferObjects/Payments/CustomerDTO.php +++ b/src/DataTransferObjects/Payments/CustomerDTO.php @@ -9,6 +9,11 @@ use Faridibin\Paystack\Traits\{HasMetadata, MapToArray}; use Faridibin\Paystack\DataTransferObjects\{Collection, Recurring\SubscriptionDTO}; +/** + * Data Transfer Object representing a Paystack customer. + * + * Includes customer details, authorizations, subscriptions, and metadata. + */ class CustomerDTO implements DataTransferObject { use HasMetadata, MapToArray; @@ -26,12 +31,12 @@ class CustomerDTO implements DataTransferObject /** * The createdAt property */ - public readonly DateTime $createdAt; + public readonly ?DateTime $createdAt; /** * The updatedAt property */ - public readonly DateTime $updatedAt; + public readonly ?DateTime $updatedAt; /** * The Customer DTO constructor. @@ -41,16 +46,16 @@ class CustomerDTO implements DataTransferObject * @param string $domain * @param string $customer_code * @param string $email - * @param mixed $first_name - * @param mixed $last_name - * @param mixed $phone + * @param string|null $first_name + * @param string|null $last_name + * @param string|null $phone * @param array $metadata * @param string $risk_action * @param array $transactions * @param array $authorizations * @param array $subscriptions * @param array $total_transaction_value - * @param mixed $total_transactions + * @param int|null $total_transactions * @param bool $identified * @param mixed $identifications * @param mixed $dedicated_account @@ -86,7 +91,7 @@ public function __construct( DateTime|string|null $created_at = null, DateTime|string|null $updated_at = null, - ...$args // TODO: Remove this + ...$args ) { $this->authorizations = new Collection($authorizations, AuthorizationDTO::class); $this->subscriptions = new Collection($subscriptions, SubscriptionDTO::class); @@ -94,20 +99,9 @@ public function __construct( $createdAt = $createdAt ?? $created_at; $updatedAt = $updatedAt ?? $updated_at; - if ($createdAt) { - $this->createdAt = $createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt); - } - - if ($updatedAt) { - $this->updatedAt = $updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt); - } + $this->createdAt = $createdAt ? ($createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt)) : null; + $this->updatedAt = $updatedAt ? ($updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt)) : null; $this->resolveMetadata($metadata); - - if (!empty($args)) { - dump([ - 'customer_args' => $args, // TODO: Remove this - ]); - } } } diff --git a/src/DataTransferObjects/Payments/InvoiceDTO.php b/src/DataTransferObjects/Payments/InvoiceDTO.php index 06fb5ba..f8a23e1 100644 --- a/src/DataTransferObjects/Payments/InvoiceDTO.php +++ b/src/DataTransferObjects/Payments/InvoiceDTO.php @@ -16,6 +16,12 @@ use Faridibin\Paystack\Traits\HasMetadata; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a Paystack invoice (payment request). + * + * Encapsulates invoice details including customer, subscription, transaction, + * plan, subaccount, and log information with proper type casting. + */ class InvoiceDTO implements DataTransferObject { use HasMetadata, MapToArray; @@ -25,21 +31,21 @@ class InvoiceDTO implements DataTransferObject * * @var Currency $currency */ - public readonly Currency $currency; + public readonly ?Currency $currency; /** * The channel of the invoice * * @var Channels $channel */ - public readonly Channels $channel; + public readonly ?Channels $channel; /** * The status of the invoice * * @var Status $status */ - public readonly Status $status; + public readonly ?Status $status; /** * The Invoice creation date @@ -164,6 +170,18 @@ class InvoiceDTO implements DataTransferObject * @param array|null $connect * @param string|null $notification_flag * @param int|null $retries + * @param string|null $request_code + * @param string|null $offline_reference + * @param string|null $pdf_url + * @param string|null $split_code + * @param string|null $due_date + * @param int|null $invoice_number + * @param int|null $pending_amount + * @param bool|null $has_invoice + * @param mixed $discount + * @param array|null $line_items + * @param array|null $tax + * @param array|null $notifications * @param mixed|null $log * @param mixed|null $authorization * @param mixed|null $customer @@ -212,6 +230,18 @@ public function __construct( public readonly ?array $connect = null, public readonly ?string $notification_flag = null, public readonly ?int $retries = null, + public readonly ?string $request_code = null, + public readonly ?string $offline_reference = null, + public readonly ?string $pdf_url = null, + public readonly ?string $split_code = null, + public readonly ?string $due_date = null, + public readonly ?int $invoice_number = null, + public readonly ?int $pending_amount = null, + public readonly ?bool $has_invoice = null, + public readonly mixed $discount = null, + public readonly ?array $line_items = null, + public readonly ?array $tax = null, + public readonly ?array $notifications = null, mixed $log = null, mixed $authorization = null, mixed $customer = null, @@ -237,7 +267,7 @@ public function __construct( DateTime|string|null $nextNotification = null, DateTime|string|null $next_notification = null, - ...$args // TODO: Remove this line + ...$args ) { $this->authorization = is_array($authorization) ? new AuthorizationDTO(...$authorization) : $authorization; $this->customer = is_array($customer) ? new CustomerDTO(...$customer) : $customer; @@ -249,17 +279,17 @@ public function __construct( $this->paid = !is_null($paid) ? (bool) $paid : null; - if ($currency && !($currency instanceof Currency)) { - $this->currency = Currency::from($currency); - } + $this->currency = $currency instanceof Currency + ? $currency + : (is_string($currency) && $currency !== '' ? Currency::from($currency) : null); - if ($channel && !($channel instanceof Channels)) { - $this->channel = Channels::from($channel); - } + $this->channel = $channel instanceof Channels + ? $channel + : (is_string($channel) && $channel !== '' ? Channels::from($channel) : null); - if ($status && !($status instanceof Status)) { - $this->status = Status::from($status); - } + $this->status = $status instanceof Status + ? $status + : (is_string($status) && $status !== '' ? Status::from($status) : null); $createdAt = $createdAt ?? $created_at; $updatedAt = $updatedAt ?? $updated_at; @@ -268,37 +298,13 @@ public function __construct( $periodEnd = $periodEnd ?? $period_end; $nextNotification = $nextNotification ?? $next_notification; - if ($createdAt) { - $this->createdAt = $createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt); - } - - if ($updatedAt) { - $this->updatedAt = $updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt); - } - - if ($paidAt) { - $this->paidAt = $paidAt instanceof DateTime ? $paidAt : new DateTime($paidAt); - } - - if ($periodStart) { - $this->periodStart = $periodStart instanceof DateTime ? $periodStart : new DateTime($periodStart); - } - - if ($periodEnd) { - $this->periodEnd = $periodEnd instanceof DateTime ? $periodEnd : new DateTime($periodEnd); - } - - if ($nextNotification) { - $this->nextNotification = $next_notification instanceof DateTime ? $nextNotification : new DateTime($nextNotification); - } + $this->createdAt = $createdAt ? ($createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt)) : null; + $this->updatedAt = $updatedAt ? ($updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt)) : null; + $this->paidAt = $paidAt ? ($paidAt instanceof DateTime ? $paidAt : new DateTime($paidAt)) : null; + $this->periodStart = $periodStart ? ($periodStart instanceof DateTime ? $periodStart : new DateTime($periodStart)) : null; + $this->periodEnd = $periodEnd ? ($periodEnd instanceof DateTime ? $periodEnd : new DateTime($periodEnd)) : null; + $this->nextNotification = $nextNotification ? ($nextNotification instanceof DateTime ? $nextNotification : new DateTime($nextNotification)) : null; $this->resolveMetadata($metadata); - - if (!empty($args)) { - dump([ - 'code' => $invoice_code, - 'invoice_args' => $args, // TODO: Remove this line - ]); - } } } diff --git a/src/DataTransferObjects/Payments/PageDTO.php b/src/DataTransferObjects/Payments/PageDTO.php index f7cd404..5cd3954 100644 --- a/src/DataTransferObjects/Payments/PageDTO.php +++ b/src/DataTransferObjects/Payments/PageDTO.php @@ -11,6 +11,12 @@ use Faridibin\Paystack\Traits\HasMetadata; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a Paystack payment page. + * + * Encapsulates payment page configuration including type, currency, + * and associated metadata. + */ class PageDTO implements DataTransferObject { use HasMetadata, MapToArray; @@ -20,14 +26,14 @@ class PageDTO implements DataTransferObject * * @var PaymentPageType $interval */ - public readonly PaymentPageType $type; + public readonly ?PaymentPageType $type; /** * The currency of the page * * @var Currency $currency */ - public readonly Currency $currency; + public readonly ?Currency $currency; /** * The Page creation date @@ -93,30 +99,19 @@ public function __construct( DateTime|string|null $createdAt = null, DateTime|string|null $updatedAt = null, - ...$args // TODO: Remove this line + ...$args ) { - if ($type && !($type instanceof PaymentPageType)) { - $this->type = PaymentPageType::from($type); - } + $this->type = $type instanceof PaymentPageType + ? $type + : (is_string($type) && $type !== '' ? PaymentPageType::from($type) : null); - if ($currency && !($currency instanceof Currency)) { - $this->currency = Currency::from($currency); - } + $this->currency = $currency instanceof Currency + ? $currency + : (is_string($currency) && $currency !== '' ? Currency::from($currency) : null); - if ($createdAt) { - $this->createdAt = $createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt); - } - - if ($updatedAt) { - $this->updatedAt = $updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt); - } + $this->createdAt = $createdAt ? ($createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt)) : null; + $this->updatedAt = $updatedAt ? ($updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt)) : null; $this->resolveMetadata($metadata); - - if (!empty($args)) { - dump([ - 'page_args' => $args, // TODO: Remove this line - ]); - } } } diff --git a/src/DataTransferObjects/Payments/SubaccountDTO.php b/src/DataTransferObjects/Payments/SubaccountDTO.php index 293dbc6..1729227 100644 --- a/src/DataTransferObjects/Payments/SubaccountDTO.php +++ b/src/DataTransferObjects/Payments/SubaccountDTO.php @@ -10,6 +10,12 @@ use Faridibin\Paystack\Traits\HasMetadata; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a Paystack subaccount. + * + * Encapsulates subaccount details including bank information, settlement + * configuration, and percentage charge settings. + */ class SubaccountDTO implements DataTransferObject { use HasMetadata, MapToArray; @@ -26,7 +32,7 @@ class SubaccountDTO implements DataTransferObject * * @var Currency $currency */ - public readonly Currency $currency; + public readonly ?Currency $currency; /** * The Subaccount creation date @@ -57,7 +63,7 @@ class SubaccountDTO implements DataTransferObject * @param string|null $primary_contact_email * @param string|null $primary_contact_phone * @param string|null $domain - * @param int|null $percentage_charge + * @param int|float|null $percentage_charge * @param string|null $settlement_bank * @param string|null $account_number * @param string|null $settlement_schedule @@ -86,7 +92,7 @@ public function __construct( public readonly ?string $primary_contact_email = null, public readonly ?string $primary_contact_phone = null, public readonly ?string $domain = null, - public readonly ?int $percentage_charge = null, + public readonly int|float|null $percentage_charge = null, public readonly ?string $settlement_bank = null, public readonly ?string $account_number = null, public readonly ?string $settlement_schedule = null, @@ -102,31 +108,20 @@ public function __construct( DateTime|string|null $created_at = null, DateTime|string|null $updated_at = null, - ...$args // TODO: Remove this line + ...$args ) { - $this->active = is_int($active) ? (bool)$active : $active; + $this->active = \is_int($active) ? (bool)$active : $active; - if ($currency && !($currency instanceof Currency)) { - $this->currency = Currency::from($currency); - } + $this->currency = $currency instanceof Currency + ? $currency + : (is_string($currency) && $currency !== '' ? Currency::from($currency) : null); - $createdAt = $createdAt ?? $created_at; - $updatedAt = $updatedAt ?? $updated_at; + $createdAt ??= $created_at; + $updatedAt ??= $updated_at; - if ($createdAt) { - $this->createdAt = $createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt); - } - - if ($updatedAt) { - $this->updatedAt = $updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt); - } + $this->createdAt = $createdAt ? ($createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt)) : null; + $this->updatedAt = $updatedAt ? ($updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt)) : null; $this->resolveMetadata($metadata); - - if (!empty($args)) { - dump([ - 'subaccount_args' => $args, // TODO: Remove this line - ]); - } } } diff --git a/src/DataTransferObjects/Payments/Transactions/TransactionDTO.php b/src/DataTransferObjects/Payments/Transactions/TransactionDTO.php index 0e43a12..bee2670 100644 --- a/src/DataTransferObjects/Payments/Transactions/TransactionDTO.php +++ b/src/DataTransferObjects/Payments/Transactions/TransactionDTO.php @@ -17,6 +17,12 @@ use Faridibin\Paystack\Traits\HasMetadata; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a Paystack transaction. + * + * Encapsulates all transaction details including customer, authorization, + * plan, subaccount, and log information with proper type casting. + */ class TransactionDTO implements DataTransferObject { use HasMetadata, MapToArray; @@ -26,21 +32,21 @@ class TransactionDTO implements DataTransferObject * * @var Currency $currency */ - public readonly Currency $currency; + public readonly ?Currency $currency; /** * The status of the transaction * * @var Status $status */ - public readonly Status $status; + public readonly ?Status $status; /** * The channel of the transaction * * @var Channels $channel */ - public readonly Channels $channel; + public readonly ?Channels $channel; /** * The Transaction creation date @@ -101,7 +107,7 @@ class TransactionDTO implements DataTransferObject /** * The subaccount of the transaction * - * @var SubaccountDTO|string|int|null $subaccount + * @var SubaccountDTO|string|null $subaccount */ public readonly SubaccountDTO|string|null $subaccount; @@ -189,7 +195,7 @@ public function __construct( DateTime|string|null $transaction_date = null, - ...$args // TODO: Remove this line + ...$args ) { $this->customer = is_array($customer) ? new CustomerDTO(...$customer) : $customer; $this->authorization = is_array($authorization) ? new AuthorizationDTO(...$authorization) : $authorization; @@ -199,47 +205,28 @@ public function __construct( $this->log = is_array($log) ? new LogDTO(...$log) : $log; - if ($status && !($status instanceof Status)) { - $this->status = Status::from($status); - } + $this->status = $status instanceof Status + ? $status + : (is_string($status) && $status !== '' ? Status::from($status) : null); - if ($channel && !($channel instanceof Channels)) { - $this->channel = Channels::from($channel); - } + $this->channel = $channel instanceof Channels + ? $channel + : (is_string($channel) && $channel !== '' ? Channels::from($channel) : null); - if ($currency && !($currency instanceof Currency)) { - $this->currency = Currency::from($currency); - } + $this->currency = $currency instanceof Currency + ? $currency + : (is_string($currency) && $currency !== '' ? Currency::from($currency) : null); $createdAt = $createdAt ?? $created_at; $updatedAt = $updatedAt ?? $updated_at; $paidAt = $paidAt ?? $paid_at; $transactionDate = $transactionDate ?? $transaction_date; - if ($createdAt) { - $this->createdAt = $createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt); - } - - if ($updatedAt) { - $this->updatedAt = $updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt); - } - - if ($paidAt) { - $this->paidAt = $paidAt instanceof DateTime ? $paidAt : new DateTime($paidAt); - } - - if ($transactionDate) { - $this->transactionDate = $transactionDate instanceof DateTime ? $transactionDate : new DateTime($transactionDate); - } + $this->createdAt = $createdAt ? ($createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt)) : null; + $this->updatedAt = $updatedAt ? ($updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt)) : null; + $this->paidAt = $paidAt ? ($paidAt instanceof DateTime ? $paidAt : new DateTime($paidAt)) : null; + $this->transactionDate = $transactionDate ? ($transactionDate instanceof DateTime ? $transactionDate : new DateTime($transactionDate)) : null; $this->resolveMetadata($metadata); - - - if (!empty($args)) { - dump([ - 'id' => $id, - 'transaction_args' => $args, // TODO: Remove this line - ]); - } } } diff --git a/src/DataTransferObjects/Payments/Transactions/TransactionTotalsDTO.php b/src/DataTransferObjects/Payments/Transactions/TransactionTotalsDTO.php index 9bcc98f..a9b0152 100644 --- a/src/DataTransferObjects/Payments/Transactions/TransactionTotalsDTO.php +++ b/src/DataTransferObjects/Payments/Transactions/TransactionTotalsDTO.php @@ -9,6 +9,12 @@ use Faridibin\Paystack\Enums\Currency; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing Paystack transaction totals. + * + * Aggregates total transaction volumes and pending transfer amounts, + * broken down by currency. + */ class TransactionTotalsDTO implements DataTransferObject { use MapToArray; @@ -43,7 +49,7 @@ public function __construct( ?array $total_volume_by_currency = null, ?array $pending_transfers_by_currency = null, - ...$args // TODO: Remove this line + ...$args ) { $this->total_volume_by_currency = new Collection( array_map( @@ -65,10 +71,5 @@ public function __construct( ) ); - if (!empty($args)) { - dump([ - 'plan_args' => $args, // TODO: Remove this - ]); - } } } diff --git a/src/DataTransferObjects/Recurring/PlanDTO.php b/src/DataTransferObjects/Recurring/PlanDTO.php index d89aa72..17e4213 100644 --- a/src/DataTransferObjects/Recurring/PlanDTO.php +++ b/src/DataTransferObjects/Recurring/PlanDTO.php @@ -12,6 +12,11 @@ use Faridibin\Paystack\Enums\Interval; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a Paystack subscription plan. + * + * Includes plan details, subscribers, subscriptions, and payment pages. + */ class PlanDTO implements DataTransferObject { use MapToArray; @@ -56,14 +61,14 @@ class PlanDTO implements DataTransferObject * * @var Currency $currency */ - public readonly Currency $currency; + public readonly ?Currency $currency; /** * The interval of the plan * * @var Interval $interval */ - public readonly Interval $interval; + public readonly ?Interval $interval; /** * The Plan creation date @@ -149,38 +154,27 @@ public function __construct( DateTime|string|null $updated_at = null, - ...$args // TODO: Remove this + ...$args ) { - $this->send_invoices = is_int($send_invoices) ? (bool)$send_invoices : $send_invoices; - $this->send_sms = is_int($send_sms) ? (bool)$send_sms : $send_sms; + $this->send_invoices = \is_int($send_invoices) ? (bool)$send_invoices : $send_invoices; + $this->send_sms = \is_int($send_sms) ? (bool)$send_sms : $send_sms; $this->pages = new Collection($pages, PageDTO::class); $this->subscriptions = new Collection($subscriptions, SubscriptionDTO::class); $this->subscribers = new Collection($subscribers, SubscriberDTO::class); - if ($currency && !($currency instanceof Currency)) { - $this->currency = Currency::from($currency); - } + $this->currency = $currency instanceof Currency + ? $currency + : (is_string($currency) && $currency !== '' ? Currency::from($currency) : null); - if ($interval && !($interval instanceof Interval)) { - $this->interval = Interval::from($interval); - } + $this->interval = $interval instanceof Interval + ? $interval + : (is_string($interval) && $interval !== '' ? Interval::from($interval) : null); - $createdAt = $createdAt ?? $created_at; - $updatedAt = $updatedAt ?? $updated_at; + $createdAt ??= $created_at; + $updatedAt ??= $updated_at; - if ($createdAt) { - $this->createdAt = $createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt); - } - - if ($updatedAt) { - $this->updatedAt = $updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt); - } - - if (!empty($args)) { - dump([ - 'plan_args' => $args, // TODO: Remove this - ]); - } + $this->createdAt = $createdAt ? ($createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt)) : null; + $this->updatedAt = $updatedAt ? ($updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt)) : null; } } diff --git a/src/DataTransferObjects/Recurring/SubscriberDTO.php b/src/DataTransferObjects/Recurring/SubscriberDTO.php index a84c60b..d99f97f 100644 --- a/src/DataTransferObjects/Recurring/SubscriberDTO.php +++ b/src/DataTransferObjects/Recurring/SubscriberDTO.php @@ -9,6 +9,12 @@ use Faridibin\Paystack\Enums\Status; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a subscriber to a Paystack plan. + * + * Encapsulates subscriber identity and their associated subscription status + * and total amount paid. + */ class SubscriberDTO implements DataTransferObject { use MapToArray; @@ -18,14 +24,14 @@ class SubscriberDTO implements DataTransferObject * * @var Currency $currency */ - public readonly Currency $currency; + public readonly ?Currency $currency; /** * The status of the subscription * * @var Status $status */ - public readonly Status $status; + public readonly ?Status $status; /** * The subscriber DTO constructor. @@ -47,20 +53,14 @@ public function __construct( Currency|string|null $currency = null, Status|string|null $subscription_status = null, - ...$args // TODO: Remove this line + ...$args ) { - if (!($currency instanceof Currency)) { - $this->currency = Currency::from($currency); - } + $this->currency = $currency instanceof Currency + ? $currency + : (is_string($currency) && $currency !== '' ? Currency::from($currency) : null); - if (!($subscription_status instanceof Status)) { - $this->status = Status::from($subscription_status); - } - - if (!empty($args)) { - dump([ - 'subscriber_args' => $args, // TODO: Remove this line - ]); - } + $this->status = $subscription_status instanceof Status + ? $subscription_status + : (is_string($subscription_status) && $subscription_status !== '' ? Status::from($subscription_status) : null); } } diff --git a/src/DataTransferObjects/Recurring/SubscriptionDTO.php b/src/DataTransferObjects/Recurring/SubscriptionDTO.php index 672b283..0b19493 100644 --- a/src/DataTransferObjects/Recurring/SubscriptionDTO.php +++ b/src/DataTransferObjects/Recurring/SubscriptionDTO.php @@ -11,11 +11,18 @@ use Faridibin\Paystack\DataTransferObjects\Payments\CustomerDTO; use Faridibin\Paystack\DataTransferObjects\Payments\InvoiceDTO; use Faridibin\Paystack\Enums\Status; +use Faridibin\Paystack\Traits\HasMetadata; use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a Paystack subscription. + * + * Encapsulates subscription details including customer, authorization, plan, + * invoices, and scheduling information. + */ class SubscriptionDTO implements DataTransferObject { - use MapToArray; + use HasMetadata, MapToArray; /** * The authorization of the subscription @@ -131,6 +138,7 @@ class SubscriptionDTO implements DataTransferObject * @param DateTime|string|null $next_payment_date * @param DateTime|string|int|null $start * @param DateTime|string|null $cancelledAt + * @param array|string|null $metadata */ public function __construct( public readonly ?int $id = null, @@ -161,8 +169,9 @@ public function __construct( DateTime|string|null $next_payment_date = null, DateTime|string|int|null $start = null, DateTime|string|null $cancelledAt = null, + array|string|null $metadata = null, - ...$args // TODO: Remove this line + ...$args ) { $this->authorization = is_array($authorization) ? new AuthorizationDTO(...$authorization) : $authorization; $this->customer = is_array($customer) ? new CustomerDTO(...$customer) : $customer; @@ -174,39 +183,21 @@ public function __construct( $createdAt = $createdAt ?? $created_at; $updatedAt = $updatedAt ?? $updated_at; - if ($createdAt) { - $this->createdAt = $createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt); - } + $this->createdAt = $createdAt ? ($createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt)) : null; + $this->updatedAt = $updatedAt ? ($updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt)) : null; + $this->next_payment_date = $next_payment_date ? ($next_payment_date instanceof DateTime ? $next_payment_date : new DateTime($next_payment_date)) : null; - if ($updatedAt) { - $this->updatedAt = $updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt); + if ($start && \is_int($start)) { + $start = date('Y-m-d H:i:s', $start); } - if ($next_payment_date) { - $this->next_payment_date = !($next_payment_date instanceof DateTime) ? new DateTime($next_payment_date) : $next_payment_date; - } + $this->start = $start ? ($start instanceof DateTime ? $start : new DateTime($start)) : null; + $this->cancelledAt = $cancelledAt ? ($cancelledAt instanceof DateTime ? $cancelledAt : new DateTime($cancelledAt)) : null; - if ($start) { - if (is_int($start)) { - $start = date('Y-m-d H:i:s', $start); - } + $this->status = $status instanceof Status + ? $status + : (is_string($status) && $status !== '' ? Status::from($status) : Status::UNKNOWN); - $this->start = !($start instanceof DateTime) ? new DateTime($start) : $start; - } - - if ($cancelledAt) { - $this->cancelledAt = !($cancelledAt instanceof DateTime) ? new DateTime($cancelledAt) : $cancelledAt; - } - - if ($status && !($status instanceof Status)) { - $this->status = Status::from($status); - } - - if (!empty($args)) { - dump([ - 'code' => $subscription_code, - 'subscription_args' => $args, // TODO: Remove this line - ]); - } + $this->resolveMetadata($metadata); } } diff --git a/src/DataTransferObjects/Relationships.php b/src/DataTransferObjects/Relationships.php index 418fac1..53a2041 100644 --- a/src/DataTransferObjects/Relationships.php +++ b/src/DataTransferObjects/Relationships.php @@ -6,8 +6,18 @@ use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; +/** + * Represents relational data in a Paystack API response. + * + * @todo Implementation is pending. + */ class Relationships implements DataTransferObject { + /** + * The raw relationships data. + */ + private readonly array $relationships; + /** * The Relationship DTO constructor. * @@ -15,23 +25,7 @@ class Relationships implements DataTransferObject */ public function __construct(array $relationships = []) { - // TODO: Implement constructor - - foreach ($relationships as $key => $relationship) { - dump($key, $relationship); - - // $this->items[$key] = $this->resolveRelationship( - // $key, - // $relationship['type'], - // $relationship['data'], - // $relationship['supported_currencies'] ?? null - // ); - } - - // dd( - // $this, - // $relationships - // ); + $this->relationships = $relationships; } /** @@ -41,7 +35,6 @@ public function __construct(array $relationships = []) */ public function toArray(): array { - // TODO: Implement toArray() method. - return []; + return $this->relationships; } } diff --git a/src/DataTransferObjects/Response.php b/src/DataTransferObjects/Response.php index d4fd0f9..dec8ee1 100644 --- a/src/DataTransferObjects/Response.php +++ b/src/DataTransferObjects/Response.php @@ -7,6 +7,12 @@ use Exception; use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; +/** + * Represents a Paystack API response, wrapping the raw HTTP response data. + * + * Handles both successful responses and exceptions, transforming the `data` + * field into the appropriate DTO or Collection. + */ class Response implements DataTransferObject { /** @@ -77,7 +83,7 @@ public function getData(): mixed * * @param string $key * @param mixed $default - * + * * @return mixed */ public function get(string $key, mixed $default = null): mixed @@ -88,7 +94,7 @@ public function get(string $key, mixed $default = null): mixed $data = $this->data->toArray(); - if (array_key_exists($key, $data)) { + if (\array_key_exists($key, $data)) { return $data[$key]; } @@ -97,7 +103,7 @@ public function get(string $key, mixed $default = null): mixed /** * Get the meta data - * + * * @return mixed */ public function getMeta(): mixed @@ -167,7 +173,7 @@ public function toArray(): array * @param string|null $dtoClass * @return void */ - private function handleResponse(array $response, string $dtoClass = null): void + private function handleResponse(array $response, ?string $dtoClass = null): void { $this->status = $response['status'] ?? false; $this->message = $response['message'] ?? ''; @@ -204,12 +210,12 @@ private function handleException(Exception $exception): void /** * Transform response data based on DTO class and collection status - * + * * @param array $data * @param string|null $dtoClass * @return mixed */ - private function transformData(array $data, ?string $dtoClass): mixed + private function transformData(array $data, ?string $dtoClass = null): mixed { if (!$dtoClass || !class_exists($dtoClass)) { return $this->isCollection diff --git a/src/DataTransferObjects/Transfers/RecipientDTO.php b/src/DataTransferObjects/Transfers/RecipientDTO.php index d9b2764..c01d75d 100644 --- a/src/DataTransferObjects/Transfers/RecipientDTO.php +++ b/src/DataTransferObjects/Transfers/RecipientDTO.php @@ -4,30 +4,94 @@ namespace Faridibin\Paystack\DataTransferObjects\Transfers; +use DateTime; use Faridibin\Paystack\Contracts\DataTransferObjects\DataTransferObject; +use Faridibin\Paystack\Enums\Currency; +use Faridibin\Paystack\Enums\RecipientType; +use Faridibin\Paystack\Traits\HasMetadata; +use Faridibin\Paystack\Traits\MapToArray; +/** + * Data Transfer Object representing a Paystack transfer recipient. + */ class RecipientDTO implements DataTransferObject { + use HasMetadata, MapToArray; + /** - * The Recipient DTO constructor. + * The createdAt property + */ + public readonly ?DateTime $createdAt; + + /** + * The updatedAt property + */ + public readonly ?DateTime $updatedAt; + + /** + * The type of the recipient * + * @var RecipientType $type */ - public function __construct( - // - ) - { - // - } + public readonly ?RecipientType $type; + + /** + * The currency of the recipient + * + * @var Currency $currency + */ + public readonly ?Currency $currency; /** - * Convert the state to an array + * Whether the recipient has been deleted. + * Paystack returns this as `isDeleted` or `is_deleted` depending on the endpoint. * - * @return array + * @var bool|null $isDeleted + */ + public readonly ?bool $isDeleted; + + /** + * The Recipient DTO constructor. */ - public function toArray(): array - { - return [ - // - ]; + public function __construct( + public readonly ?int $id = null, + public readonly ?int $integration = null, + public readonly ?string $domain = null, + public readonly ?string $name = null, + public readonly ?string $description = null, + public readonly ?string $recipient_code = null, + public readonly ?bool $active = null, + public readonly ?string $recipient_account = null, + public readonly ?string $institution_code = null, + public readonly ?string $email = null, + bool|null $isDeleted = null, + bool|null $is_deleted = null, + public readonly array $details = [], + RecipientType|string|null $type = null, + Currency|string|null $currency = null, + array|string|null $metadata = null, + DateTime|string|null $createdAt = null, + DateTime|string|null $updatedAt = null, + DateTime|string|null $created_at = null, + DateTime|string|null $updated_at = null, + ...$args + ) { + $this->isDeleted = $isDeleted ?? $is_deleted; + + $this->type = $type instanceof RecipientType + ? $type + : (is_string($type) && $type !== '' ? RecipientType::from($type) : null); + + $this->currency = $currency instanceof Currency + ? $currency + : (is_string($currency) && $currency !== '' ? Currency::from($currency) : null); + + $createdAt ??= $created_at; + $updatedAt ??= $updated_at; + + $this->createdAt = $createdAt ? ($createdAt instanceof DateTime ? $createdAt : new DateTime($createdAt)) : null; + $this->updatedAt = $updatedAt ? ($updatedAt instanceof DateTime ? $updatedAt : new DateTime($updatedAt)) : null; + + $this->resolveMetadata($metadata); } } diff --git a/src/Enums/BankType.php b/src/Enums/BankType.php index 691fcd1..70eb090 100644 --- a/src/Enums/BankType.php +++ b/src/Enums/BankType.php @@ -1,7 +1,12 @@ $channel instanceof Channels ? $channel->value : $channel, $channels diff --git a/src/Enums/Currency.php b/src/Enums/Currency.php index 19acfe2..3c8fbf6 100644 --- a/src/Enums/Currency.php +++ b/src/Enums/Currency.php @@ -1,7 +1,12 @@ 'Mobile Money or MoMo is an account tied to a mobile number', self::KEPSS => 'Kenya Electronic Payment and Settlement System', self::NUBAN => 'Nigerian Uniform Bank Account Number', - self::BASA => 'Banking Association South Africa' + self::BASA => 'Banking Association South Africa', + self::AUTHORIZATION => 'A reusable authorization code from a previous charge', }; } @@ -39,6 +46,7 @@ public function getCurrencies(): array self::KEPSS => ['KES'], self::NUBAN => ['NGN'], self::BASA => ['ZAR'], + self::AUTHORIZATION => ['NGN', 'GHS', 'KES', 'ZAR', 'USD'], }; } } diff --git a/src/Enums/Resolution.php b/src/Enums/Resolution.php index 279f35d..075a2b0 100644 --- a/src/Enums/Resolution.php +++ b/src/Enums/Resolution.php @@ -1,7 +1,12 @@ $stat instanceof Status ? $stat->value : $stat, $status diff --git a/src/Enums/TransferSource.php b/src/Enums/TransferSource.php index e1f2c89..c031e14 100644 --- a/src/Enums/TransferSource.php +++ b/src/Enums/TransferSource.php @@ -1,7 +1,12 @@ serviceMap[$name] = [$serviceClass, $interfaceClass]; @@ -75,12 +83,12 @@ public function registerService(string $name, string $serviceClass, string $inte } /** - * Register multiple services. + * Register multiple services at once. * - * @param array $services - * @return self + * @param array $services Map of alias => [serviceClass, interfaceClass] + * @return static */ - public function registerServices(array $services): self + public function registerServices(array $services): static { foreach ($services as $name => [$serviceClass, $interfaceClass]) { $this->registerService($name, $serviceClass, $interfaceClass); diff --git a/src/Services/Balance.php b/src/Services/Balance.php new file mode 100644 index 0000000..4e613b8 --- /dev/null +++ b/src/Services/Balance.php @@ -0,0 +1,43 @@ +client = $client ?? new Client($secretKey); + } + + /** + * Fetch Balance + * Fetch the balance of your integration + * + * @return Response + */ + public function fetch(): Response + { + $response = $this->client->send('GET', '/balance'); + + return new Response($response); + } +} diff --git a/src/Services/Commerce/PaymentPages.php b/src/Services/Commerce/PaymentPages.php index 46f06ad..07e02b4 100644 --- a/src/Services/Commerce/PaymentPages.php +++ b/src/Services/Commerce/PaymentPages.php @@ -1,5 +1,7 @@ client->send('GET', "/page/{$identifier}"); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** @@ -92,7 +87,7 @@ public function fetchPaymentPage(string $identifier): Response * * @param string $identifier * @param array $data - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function updatePaymentPage(string $identifier, array $data): Response { @@ -100,11 +95,7 @@ public function updatePaymentPage(string $identifier, array $data): Response 'json' => $data ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** @@ -112,17 +103,13 @@ public function updatePaymentPage(string $identifier, array $data): Response * Check if a slug is available for use on your integration * * @param string $slug - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function checkSlugAvailability(string $slug): Response { $response = $this->client->send('GET', "/page/check_slug_availability/{$slug}"); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** @@ -131,7 +118,7 @@ public function checkSlugAvailability(string $slug): Response * * @param string|int $id * @param array $products - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function addProduct(string|int $id, array $products): Response { @@ -141,10 +128,6 @@ public function addProduct(string|int $id, array $products): Response ] ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response( $response); } } diff --git a/src/Services/Commerce/Products.php b/src/Services/Commerce/Products.php index d1146a3..892cd71 100644 --- a/src/Services/Commerce/Products.php +++ b/src/Services/Commerce/Products.php @@ -1,5 +1,7 @@ $data ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** * List Products. * List products available on your integration. * - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function listProducts(int $perPage = 50, int $page = 1, array $optional = []): Response { @@ -101,4 +104,18 @@ public function listProducts(int $perPage = 50, int $page = 1, array $optional = return new Response($response); } + + /** + * Delete a product. + * Delete a previously created product on your integration + * + * @param string $id + * @return Response + */ + public function deleteProduct(string $id): Response + { + $response = $this->client->send('DELETE', "/product/{$id}"); + + return new Response($response); + } } diff --git a/src/Services/DirectDebit.php b/src/Services/DirectDebit.php new file mode 100644 index 0000000..0b7a883 --- /dev/null +++ b/src/Services/DirectDebit.php @@ -0,0 +1,68 @@ +client = $client ?? new Client($secretKey); + } + + /** + * Trigger Activation Charge + * Trigger an activation charge for Direct Debit customers + * + * @param array $data + * @return Response + */ + public function triggerActivationCharge(array $data): Response + { + $response = $this->client->send('PUT', '/directdebit/activation-charge', [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * List Mandate Authorizations + * List all Direct Debit mandate authorizations on your integration + * + * @param int $perPage + * @param int $page + * @param array $optional + * @return Response + */ + public function listMandateAuthorizations(int $perPage = 50, int $page = 1, array $optional = []): Response + { + $response = $this->client->send('GET', '/directdebit/mandate-authorizations', [ + 'query' => [ + 'perPage' => $perPage, + 'page' => $page, + ...$optional + ] + ]); + + return new Response($response); + } +} diff --git a/src/Services/Integration.php b/src/Services/Integration.php index 8228f53..302b417 100644 --- a/src/Services/Integration.php +++ b/src/Services/Integration.php @@ -1,5 +1,7 @@ client = $client ?? new Client($secretKey); + } + + /** + * Create Order + * Create an order on your integration + * + * @param array $data + * @return Response + */ + public function create(array $data): Response + { + $response = $this->client->send('POST', '/order', [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * List Orders + * List the orders available on your integration + * + * @param int $perPage + * @param int $page + * @param array $optional + * @return Response + */ + public function list(int $perPage = 50, int $page = 1, array $optional = []): Response + { + $response = $this->client->send('GET', '/order', [ + 'query' => [ + 'perPage' => $perPage, + 'page' => $page, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Fetch Order + * Get details of an order on your integration + * + * @param string $id The order ID + * @return Response + */ + public function fetch(string $id): Response + { + $response = $this->client->send('GET', "/order/{$id}"); + + return new Response($response); + } + + /** + * Fetch Product Orders + * Fetch all orders for a particular product + * + * @param string $productId The product ID + * @return Response + */ + public function fetchByProduct(string $productId): Response + { + $response = $this->client->send('GET', "/order/product/{$productId}"); + + return new Response($response); + } + + /** + * Validate Order + * Validate a pay for me order + * + * @param string $code The order code + * @return Response + */ + public function validate(string $code): Response + { + $response = $this->client->send('GET', "/order/{$code}/validate"); + + return new Response($response); + } +} diff --git a/src/Services/Payments/ApplePay.php b/src/Services/Payments/ApplePay.php index d1daa58..40f135f 100644 --- a/src/Services/Payments/ApplePay.php +++ b/src/Services/Payments/ApplePay.php @@ -1,5 +1,7 @@ client->send('GET', '/apple-pay/domain', [ 'query' => [ - 'name' => $useCursor, + 'use_cursor' => $useCursor, 'next' => $next, 'previous' => $previous, ] @@ -63,7 +70,7 @@ public function listDomains(bool $useCursor = false, string $next = '', string $ * Unregister a top-level domain or subdomain previously used for your Apple Pay integration. * * @param string $domain - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function unregisterDomain(string $domain): Response { diff --git a/src/Services/Payments/BulkCharges.php b/src/Services/Payments/BulkCharges.php index 99ae8aa..cf90a24 100644 --- a/src/Services/Payments/BulkCharges.php +++ b/src/Services/Payments/BulkCharges.php @@ -1,5 +1,7 @@ client->send('POST', 'bulkcharge', [ + $response = $this->client->send('POST', '/bulkcharge', [ 'json' => $data ]); @@ -48,7 +55,7 @@ public function initiateBulkCharge(array $data = []): Response */ public function listBulkChargeBatches(int $perPage = 50, int $page = 1, array $optional = []): Response { - $response = $this->client->send('GET', 'bulkcharge', [ + $response = $this->client->send('GET', '/bulkcharge', [ 'query' => [ 'perPage' => $perPage, 'page' => $page, @@ -68,7 +75,7 @@ public function listBulkChargeBatches(int $perPage = 50, int $page = 1, array $o */ public function fetchBulkChargeBatch(string $identifier): Response { - $response = $this->client->send('GET', "bulkcharge/{$identifier}"); + $response = $this->client->send('GET', "/bulkcharge/{$identifier}"); return new Response($response); } @@ -90,7 +97,7 @@ public function fetchBulkChargeBatch(string $identifier): Response */ public function fetchChargesInBatch(string $identifier, Status|string $status, int $perPage = 50, int $page = 1, array $optional = []): Response { - $response = $this->client->send('GET', "bulkcharge/{$identifier}/charges", [ + $response = $this->client->send('GET', "/bulkcharge/{$identifier}/charges", [ 'query' => [ 'status' => $status, 'perPage' => $perPage, @@ -111,7 +118,7 @@ public function fetchChargesInBatch(string $identifier, Status|string $status, i */ public function pauseBulkChargeBatch(string $code): Response { - $response = $this->client->send('POST', "bulkcharge/pause/{$code}"); + $response = $this->client->send('GET', "/bulkcharge/pause/{$code}"); return new Response($response); } @@ -125,7 +132,7 @@ public function pauseBulkChargeBatch(string $code): Response */ public function resumeBulkChargeBatch(string $code): Response { - $response = $this->client->send('POST', "bulkcharge/resume/{$code}"); + $response = $this->client->send('GET', "/bulkcharge/resume/{$code}"); return new Response($response); } diff --git a/src/Services/Payments/Charge.php b/src/Services/Payments/Charge.php index 63b105c..997bff0 100644 --- a/src/Services/Payments/Charge.php +++ b/src/Services/Payments/Charge.php @@ -1,5 +1,7 @@ client->send('POST', '/customer/deactivate_authorization', [ + $response = $this->client->send('POST', '/customer/authorization/deactivate', [ 'json' => [ 'authorization_code' => $authorizationCode, ] @@ -146,4 +153,88 @@ public function deactivateAuthorization(string $authorizationCode): Response return new Response($response); } + + /** + * Initialize Authorization. + * Initiate a reusable authorization request to your customers + * + * @param string $email + * @param string $channel The authorization channel. Currently only 'direct_debit' is supported. + * @param array $optional + * @return Response + */ + public function initializeAuthorization(string $email, string $channel = 'direct_debit', array $optional = []): Response + { + $response = $this->client->send('POST', '/customer/authorization/initialize', [ + 'json' => [ + 'email' => $email, + 'channel' => $channel, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Verify Authorization. + * Check the status of an authorization request + * + * @param string $reference + * @return Response + */ + public function verifyAuthorization(string $reference): Response + { + $response = $this->client->send('GET', "/customer/authorization/verify/{$reference}"); + + return new Response($response); + } + + /** + * Initialize Direct Debit. + * Link a customer's account for Direct Debit + * + * @param string $customerId + * @param array $data + * @return Response + */ + public function initializeDirectDebit(string $customerId, array $data): Response + { + $response = $this->client->send('POST', "/customer/{$customerId}/initialize-direct-debit", [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * Direct Debit Activation Charge. + * Trigger the activation charge for a Direct Debit mandate + * + * @param string $customerId + * @param array $data + * @return Response + */ + public function directDebitActivationCharge(string $customerId, array $data): Response + { + $response = $this->client->send('PUT', "/customer/{$customerId}/directdebit-activation-charge", [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * Fetch Mandate Authorizations. + * Get a customer's Direct Debit mandate authorizations + * + * @param string $customerId + * @return Response + */ + public function fetchMandateAuthorizations(string $customerId): Response + { + $response = $this->client->send('GET', "/customer/{$customerId}/directdebit-mandate-authorizations"); + + return new Response($response); + } } diff --git a/src/Services/Payments/DedicatedAccount.php b/src/Services/Payments/DedicatedAccount.php new file mode 100644 index 0000000..3ef6130 --- /dev/null +++ b/src/Services/Payments/DedicatedAccount.php @@ -0,0 +1,203 @@ +client = $client ?? new Client($secretKey); + } + + /** + * Create Dedicated Account + * Create a dedicated virtual account for a customer + * + * @param string $customer Customer ID or code + * @param array $optional + * @return Response + */ + public function create(string $customer, array $optional = []): Response + { + $response = $this->client->send('POST', '/dedicated_account', [ + 'json' => [ + 'customer' => $customer, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * List Dedicated Accounts + * List dedicated virtual accounts available on your integration + * + * @param int $perPage + * @param int $page + * @param array $optional + * @return Response + */ + public function list(int $perPage = 50, int $page = 1, array $optional = []): Response + { + $response = $this->client->send('GET', '/dedicated_account', [ + 'query' => [ + 'perPage' => $perPage, + 'page' => $page, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Assign Dedicated Account + * Create a customer, validate the customer, and assign a DVA to the customer + * + * @param string $email Customer email + * @param string $firstName Customer first name + * @param string $lastName Customer last name + * @param string $phone Customer phone number + * @param string $preferredBank Bank slug for preferred bank (e.g. wema-bank) + * @param string $country Country code (e.g. NG) + * @param array $optional + * @return Response + */ + public function assign(string $email, string $firstName, string $lastName, string $phone, string $preferredBank, string $country, array $optional = []): Response + { + $response = $this->client->send('POST', '/dedicated_account/assign', [ + 'json' => [ + 'email' => $email, + 'first_name' => $firstName, + 'last_name' => $lastName, + 'phone' => $phone, + 'preferred_bank' => $preferredBank, + 'country' => $country, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Fetch Dedicated Account + * Get details of a dedicated virtual account on your integration + * + * @param string $dedicatedAccountId + * @return Response + */ + public function fetch(string $dedicatedAccountId): Response + { + $response = $this->client->send('GET', "/dedicated_account/{$dedicatedAccountId}"); + + return new Response($response); + } + + /** + * Deactivate Dedicated Account + * Deactivate a dedicated virtual account on your integration + * + * @param string $dedicatedAccountId + * @return Response + */ + public function deactivate(string $dedicatedAccountId): Response + { + $response = $this->client->send('DELETE', "/dedicated_account/{$dedicatedAccountId}"); + + return new Response($response); + } + + /** + * Requery Dedicated Account + * Requery Dedicated Virtual Account for new transactions + * + * @param string $accountNumber Virtual account number to requery + * @param string $providerSlug The bank's slug in lowercase, without spaces + * @param array $optional + * @return Response + */ + public function requery(string $accountNumber, string $providerSlug, array $optional = []): Response + { + $response = $this->client->send('GET', '/dedicated_account/requery', [ + 'query' => [ + 'account_number' => $accountNumber, + 'provider_slug' => $providerSlug, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Add Split to Dedicated Account + * Split a dedicated virtual account transaction with one or more accounts + * + * @param string $customer Customer ID, code, or email + * @param array $optional + * @return Response + */ + public function addSplit(string $customer, array $optional = []): Response + { + $response = $this->client->send('POST', '/dedicated_account/split', [ + 'json' => [ + 'customer' => $customer, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Remove Split from Dedicated Account + * Remove a split from a dedicated virtual account + * + * @param string $accountNumber Dedicated virtual account number + * @return Response + */ + public function removeSplit(string $accountNumber): Response + { + $response = $this->client->send('DELETE', '/dedicated_account/split', [ + 'json' => [ + 'account_number' => $accountNumber + ] + ]); + + return new Response($response); + } + + /** + * Fetch Bank Providers + * Get available bank providers for a dedicated virtual account + * + * @return Response + */ + public function availableProviders(): Response + { + $response = $this->client->send('GET', '/dedicated_account/available_providers'); + + return new Response($response); + } +} diff --git a/src/Services/Payments/Disputes.php b/src/Services/Payments/Disputes.php index 7d2efc0..4d52976 100644 --- a/src/Services/Payments/Disputes.php +++ b/src/Services/Payments/Disputes.php @@ -1,5 +1,7 @@ client->send('PUT', "dispute/{$id}", [ + $response = $this->client->send('PUT', "/dispute/{$id}", [ 'json' => $data ]); @@ -107,11 +114,11 @@ public function updateDispute(string $id, array $data): Response * @param string $customerPhone * @param string $serviceDetails * @param array $optional - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function addEvidence(string $id, string $customerEmail, string $customerName, string $customerPhone, string $serviceDetails, array $optional = []): Response { - $response = $this->client->send('POST', "dispute/{$id}/evidence", [ + $response = $this->client->send('POST', "/dispute/{$id}/evidence", [ 'json' => [ 'customer_email' => $customerEmail, 'customer_name' => $customerName, @@ -130,7 +137,7 @@ public function addEvidence(string $id, string $customerEmail, string $customerN * * @param string $id * @param string $filename - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function getUploadUrl(string $id, string $filename): Response { @@ -152,11 +159,11 @@ public function getUploadUrl(string $id, string $filename): Response * @param string $message * @param int $refundAmount * @param string $filename - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function resolveDispute(string $id, Resolution|string $resolution, string $message, int $refundAmount, string $filename, array $optional = []): Response { - $response = $this->client->send('PUT', "dispute/{$id}/resolve", [ + $response = $this->client->send('PUT', "/dispute/{$id}/resolve", [ 'json' => [ 'resolution' => $resolution, 'message' => $message, @@ -178,7 +185,7 @@ public function resolveDispute(string $id, Resolution|string $resolution, string * @param int $perPage * @param int $page * @param array $optional - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function exportDisputes(DateTime|string $from, DateTime|string $to, int $perPage = 50, int $page = 1, array $optional = []): Response { diff --git a/src/Services/Payments/PaymentRequests.php b/src/Services/Payments/PaymentRequests.php index a57d501..7c3e24f 100644 --- a/src/Services/Payments/PaymentRequests.php +++ b/src/Services/Payments/PaymentRequests.php @@ -1,5 +1,7 @@ client->send('POST', "/retry_with_customer_details/{$identifier}", [ + $response = $this->client->send('POST', "/refund/retry_with_customer_details/{$identifier}", [ 'json' => [ 'refund_account_details' => [ 'currency' => $currency, @@ -68,11 +71,7 @@ public function retryRefund(string $identifier, Currency|string $currency, strin ] ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** @@ -82,7 +81,7 @@ public function retryRefund(string $identifier, Currency|string $currency, strin * @param string $transactionId * @param Currency|string $currency * @param array $optional - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function listRefunds(string $transactionId, Currency|string $currency, array $optional = []): Response { @@ -94,11 +93,7 @@ public function listRefunds(string $transactionId, Currency|string $currency, ar ] ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** @@ -106,16 +101,12 @@ public function listRefunds(string $transactionId, Currency|string $currency, ar * Get details of a refund on your integration * * @param string $id - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function fetchRefund(string $id): Response { $response = $this->client->send('GET', "/refund/{$id}"); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } } diff --git a/src/Services/Payments/Settlements.php b/src/Services/Payments/Settlements.php index bbc5e73..108781e 100644 --- a/src/Services/Payments/Settlements.php +++ b/src/Services/Payments/Settlements.php @@ -1,5 +1,7 @@ client->send('POST', '/subaccount', [ 'json' => [ 'business_name' => $businessName, - 'bank_code' => $bankCode, + 'settlement_bank' => $settlementBank, 'account_number' => $accountNumber, 'percentage_charge' => $percentageCharge, ...$optional @@ -51,8 +58,10 @@ public function createSubaccount(string $businessName, string $bankCode, string * List Subaccounts * List subaccounts available on your integration * - * @param string $id - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @param int $perPage Number of records to return per page (default: 50) + * @param int $page Page number to return (default: 1) + * @param array $optional Additional query parameters to filter the list of subaccounts + * @return Response */ public function listSubaccounts(int $perPage = 50, int $page = 1, array $optional = []): Response { @@ -72,7 +81,7 @@ public function listSubaccounts(int $perPage = 50, int $page = 1, array $optiona * Get details of a subaccount on your integration * * @param string $identifier The subaccount ID or code you want to fetch - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function fetchSubaccount(string $identifier): Response { @@ -87,7 +96,7 @@ public function fetchSubaccount(string $identifier): Response * * @param string $identifier The subaccount ID or code you want to update * @param array $data - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function updateSubaccount(string $identifier, array $data): Response { diff --git a/src/Services/Payments/Terminal.php b/src/Services/Payments/Terminal.php index 219cd2f..77a1d78 100644 --- a/src/Services/Payments/Terminal.php +++ b/src/Services/Payments/Terminal.php @@ -1,17 +1,25 @@ client = $client ?? new Client($secretKey); } + + /** + * Send Event + * Send an event from your application to the Paystack Terminal + * + * @param string $terminalId The ID of the Terminal the event should be sent to + * @param string $type The type of event to send (invoice, transaction) + * @param string $action The action the Terminal needs to take (process, view, print) + * @param array $data The data sent with the event + * @return Response + */ + public function sendEvent(string $terminalId, string $type, string $action, array $data): Response + { + $response = $this->client->send('POST', "/terminal/{$terminalId}/event", [ + 'json' => [ + 'type' => $type, + 'action' => $action, + 'data' => $data + ] + ]); + + return new Response($response); + } + + /** + * Fetch Event Status + * Check the status of an event sent to the Terminal + * + * @param string $terminalId The ID of the Terminal + * @param string $eventId The ID of the event that was sent to the Terminal + * @return Response + */ + public function fetchEventStatus(string $terminalId, string $eventId): Response + { + $response = $this->client->send('GET', "/terminal/{$terminalId}/event/{$eventId}"); + + return new Response($response); + } + + /** + * Fetch Terminal Status + * Check the availability of a Terminal before sending an event to it + * + * @param string $terminalId The ID of the Terminal + * @return Response + */ + public function fetchTerminalStatus(string $terminalId): Response + { + $response = $this->client->send('GET', "/terminal/{$terminalId}/presence"); + + return new Response($response); + } + + /** + * List Terminals + * List the Terminals available on your integration + * + * @param int $perPage + * @param int $page + * @param array $optional + * @return Response + */ + public function list(int $perPage = 50, int $page = 1, array $optional = []): Response + { + $response = $this->client->send('GET', '/terminal', [ + 'query' => [ + 'perPage' => $perPage, + 'page' => $page, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Fetch Terminal + * Get the details of a Terminal + * + * @param string $terminalId The ID of the Terminal + * @return Response + */ + public function fetch(string $terminalId): Response + { + $response = $this->client->send('GET', "/terminal/{$terminalId}"); + + return new Response($response); + } + + /** + * Update Terminal + * Update the details of a Terminal + * + * @param string $terminalId The ID of the Terminal + * @param array $data + * @return Response + */ + public function update(string $terminalId, array $data): Response + { + $response = $this->client->send('PUT', "/terminal/{$terminalId}", [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * Commission Terminal + * Activate your debug device by linking it to your integration + * + * @param string $serialNumber The serial number of the device to commission + * @return Response + */ + public function commission(string $serialNumber): Response + { + $response = $this->client->send('POST', '/terminal/commission_device', [ + 'json' => [ + 'serial_number' => $serialNumber + ] + ]); + + return new Response($response); + } + + /** + * Decommission Terminal + * Unlink your debug device from your integration + * + * @param string $serialNumber The serial number of the device to decommission + * @return Response + */ + public function decommission(string $serialNumber): Response + { + $response = $this->client->send('POST', '/terminal/decommission_device', [ + 'json' => [ + 'serial_number' => $serialNumber + ] + ]); + + return new Response($response); + } } diff --git a/src/Services/Payments/Transactions/Splits.php b/src/Services/Payments/Transactions/Splits.php index be22526..0eedf25 100644 --- a/src/Services/Payments/Transactions/Splits.php +++ b/src/Services/Payments/Transactions/Splits.php @@ -1,5 +1,7 @@ $type, 'currency' => $currency, 'subaccounts' => $subaccounts, - 'bearer' => $bearer, + 'bearer_type' => $bearer, 'bearer_subaccount' => $bearerSubaccount ] ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** @@ -65,7 +68,7 @@ public function createSplit(string $name, SplitType|string $type, Currency|strin * @param int $perPage * @param int $page * @param array $optional - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function listSplit(string $name, bool $active = true, int $perPage = 50, int $page = 1, array $optional = []): Response { @@ -87,7 +90,7 @@ public function listSplit(string $name, bool $active = true, int $perPage = 50, * Get details of a split on your integration * * @param string $id - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function fetchSplit(string $id): Response { @@ -102,7 +105,7 @@ public function fetchSplit(string $id): Response * * @param string $id * @param array $data - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function updateSplit(string $id, array $data): Response { @@ -110,11 +113,7 @@ public function updateSplit(string $id, array $data): Response 'json' => $data ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** @@ -124,7 +123,7 @@ public function updateSplit(string $id, array $data): Response * @param string $id * @param string $subaccount * @param int $share - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function addSubaccountSplit(string $id, string $subaccount, int $share): Response { @@ -135,11 +134,7 @@ public function addSubaccountSplit(string $id, string $subaccount, int $share): ] ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** @@ -148,7 +143,7 @@ public function addSubaccountSplit(string $id, string $subaccount, int $share): * * @param string $id * @param string $subaccount - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function removeSubaccountSplit(string $id, string $subaccount): Response { @@ -158,10 +153,6 @@ public function removeSubaccountSplit(string $id, string $subaccount): Response ] ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } } diff --git a/src/Services/Payments/Transactions/Transactions.php b/src/Services/Payments/Transactions/Transactions.php index dfcd5cd..5d0a124 100644 --- a/src/Services/Payments/Transactions/Transactions.php +++ b/src/Services/Payments/Transactions/Transactions.php @@ -1,5 +1,7 @@ client->send('POST', '/transaction/charge_authorization', [ + $response = $this->client->send('POST', '/transaction/partial_debit', [ 'json' => [ 'currency' => $currency, 'amount' => $amount, diff --git a/src/Services/Recurring/Plans.php b/src/Services/Recurring/Plans.php index 2002a07..083b148 100644 --- a/src/Services/Recurring/Plans.php +++ b/src/Services/Recurring/Plans.php @@ -1,5 +1,7 @@ client = $client ?? new Client($secretKey); + } + + /** + * Create Storefront + * Create a storefront on your integration + * + * @param array $data + * @return Response + */ + public function create(array $data): Response + { + $response = $this->client->send('POST', '/storefront', [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * List Storefronts + * List the storefronts on your integration + * + * @param int $perPage + * @param int $page + * @param array $optional + * @return Response + */ + public function list(int $perPage = 50, int $page = 1, array $optional = []): Response + { + $response = $this->client->send('GET', '/storefront', [ + 'query' => [ + 'perPage' => $perPage, + 'page' => $page, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Fetch Storefront + * Get the details of a storefront on your integration + * + * @param string $id The storefront ID + * @return Response + */ + public function fetch(string $id): Response + { + $response = $this->client->send('GET', "/storefront/{$id}"); + + return new Response($response); + } + + /** + * Update Storefront + * Update an existing storefront on your integration + * + * @param string $id The storefront ID + * @param array $data + * @return Response + */ + public function update(string $id, array $data): Response + { + $response = $this->client->send('PUT', "/storefront/{$id}", [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * Delete Storefront + * Delete a storefront on your integration + * + * @param string $id The storefront ID + * @return Response + */ + public function delete(string $id): Response + { + $response = $this->client->send('DELETE', "/storefront/{$id}"); + + return new Response($response); + } + + /** + * Verify Storefront Slug + * Verify the availability of a storefront slug + * + * @param string $slug The slug to verify + * @return Response + */ + public function verifySlug(string $slug): Response + { + $response = $this->client->send('GET', "/storefront/verify/{$slug}"); + + return new Response($response); + } + + /** + * Duplicate Storefront + * Duplicate an existing storefront on your integration + * + * @param string $id The storefront ID + * @return Response + */ + public function duplicate(string $id): Response + { + $response = $this->client->send('POST', "/storefront/{$id}/duplicate"); + + return new Response($response); + } + + /** + * Publish Storefront + * Publish a storefront on your integration + * + * @param string $id The storefront ID + * @return Response + */ + public function publish(string $id): Response + { + $response = $this->client->send('POST', "/storefront/{$id}/publish"); + + return new Response($response); + } + + /** + * Fetch Storefront Orders + * List the orders for a storefront + * + * @param string $id The storefront ID + * @return Response + */ + public function fetchOrders(string $id): Response + { + $response = $this->client->send('GET', "/storefront/{$id}/order"); + + return new Response($response); + } + + /** + * List Storefront Products + * List the products attached to a storefront + * + * @param string $id The storefront ID + * @return Response + */ + public function listProducts(string $id): Response + { + $response = $this->client->send('GET', "/storefront/{$id}/product"); + + return new Response($response); + } + + /** + * Add Products to Storefront + * Add one or more products to a storefront + * + * @param string $id The storefront ID + * @param array $data + * @return Response + */ + public function addProducts(string $id, array $data): Response + { + $response = $this->client->send('POST', "/storefront/{$id}/product", [ + 'json' => $data + ]); + + return new Response($response); + } +} diff --git a/src/Services/Transfers/Control.php b/src/Services/Transfers/Control.php index 1fae89f..92e36ae 100644 --- a/src/Services/Transfers/Control.php +++ b/src/Services/Transfers/Control.php @@ -1,5 +1,7 @@ client->send('POST', '/transfer', [ 'json' => [ 'amount' => $amount, 'recipient' => $recipient, + 'reference' => $reference, 'source' => TransferSource::BALANCE, ...$optional ] @@ -53,7 +62,7 @@ public function initiateTransfer(int $amount, string $recipient, array $optional * * @param string $transferCode * @param string $otp - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function finalizeTransfer(string $transferCode, string $otp): Response { @@ -74,11 +83,11 @@ public function finalizeTransfer(string $transferCode, string $otp): Response * * @param Currency|string $currency * @param array $transfers - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function initiateBulkTransfer(Currency|string $currency, array $transfers): Response { - $response = $this->client->send('POST', '/transfer/finalize_transfer', [ + $response = $this->client->send('POST', '/transfer/bulk', [ 'json' => [ 'currency' => $currency, 'source' => TransferSource::BALANCE, @@ -97,7 +106,7 @@ public function initiateBulkTransfer(Currency|string $currency, array $transfers * @param int $perPage * @param int $page * @param array $optional - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function listTransfers(string $recipient, int $perPage = 50, int $page = 1, array $optional = []): Response { @@ -118,7 +127,7 @@ public function listTransfers(string $recipient, int $perPage = 50, int $page = * Get details of a transfer on your integration. * * @param string $identifier - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function fetchTransfer(string $identifier): Response { @@ -132,7 +141,7 @@ public function fetchTransfer(string $identifier): Response * Verify the status of a transfer on your integration. * * @param string $reference - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @return Response */ public function verifyTransfer(string $reference): Response { @@ -140,4 +149,92 @@ public function verifyTransfer(string $reference): Response return new Response($response); } + + /** + * Export Transfers + * Export a list of transfers you made on your integration + * + * @param int $perPage + * @param int $page + * @param array $optional + * @return Response + */ + public function exportTransfers(int $perPage = 50, int $page = 1, array $optional = []): Response + { + $response = $this->client->send('GET', '/transfer/export', [ + 'query' => [ + 'perPage' => $perPage, + 'page' => $page, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Resend OTP for Transfer + * Generates a new OTP and sends to customer in the event they are having trouble receiving one. + * + * @param string $transferCode + * @param string $reason + * @return Response + */ + public function resendOtp(string $transferCode, string $reason): Response + { + $response = $this->client->send('POST', '/transfer/resend_otp', [ + 'json' => [ + 'transfer_code' => $transferCode, + 'reason' => $reason + ] + ]); + + return new Response($response); + } + + /** + * Disable OTP Requirement for Transfers + * This is used in the event that you want to be able to complete transfers programmatically without use of OTPs. + * No arguments required. + * + * @return Response + */ + public function disableOtp(): Response + { + $response = $this->client->send('POST', '/transfer/disable_otp'); + + return new Response($response); + } + + /** + * Finalize Disabling of OTP Requirement for Transfers + * Finalize the request to disable OTP on your transfers. + * + * @param string $otp + * @return Response + */ + public function disableOtpFinalize(string $otp): Response + { + $response = $this->client->send('POST', '/transfer/disable_otp_finalize', [ + 'json' => [ + 'otp' => $otp + ] + ]); + + return new Response($response); + } + + /** + * Enable OTP Requirement for Transfers + * In the event that a transfer was initiated without OTP, Paystack will stop sending OTP to your business number. + * Use this endpoint to enable OTP again. + * + * @return Response + */ + public function enableOtp(): Response + { + $response = $this->client->send('POST', '/transfer/enable_otp'); + + return new Response($response); + } } diff --git a/src/Services/Verification.php b/src/Services/Verification.php index 53ee82d..c9524c3 100644 --- a/src/Services/Verification.php +++ b/src/Services/Verification.php @@ -1,5 +1,7 @@ client->send('GET', "/decision/bin/{$bin}"); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } /** - * Resolve a card bin - * Get more information about a customer's card + * Validate an account + * Confirm the authenticity of a customer's account number before sending money * - * @param string $bin - * @return \Faridibin\Paystack\DataTransferObjects\Response + * @param array $data + * @return Response */ public function validateAccount(array $data): Response { @@ -76,10 +75,6 @@ public function validateAccount(array $data): Response 'json' => $data ]); - return new Response( - $response, - // CountriesDTO::class, - // true - ); + return new Response($response); } } diff --git a/src/Services/VirtualTerminal.php b/src/Services/VirtualTerminal.php new file mode 100644 index 0000000..43a9bba --- /dev/null +++ b/src/Services/VirtualTerminal.php @@ -0,0 +1,177 @@ +client = $client ?? new Client($secretKey); + } + + /** + * Create Virtual Terminal + * Create a Virtual Terminal on your integration + * + * @param array $data + * @return Response + */ + public function create(array $data): Response + { + $response = $this->client->send('POST', '/virtual_terminal', [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * List Virtual Terminals + * List the Virtual Terminals available on your integration + * + * @param int $perPage + * @param int $page + * @param array $optional + * @return Response + */ + public function list(int $perPage = 50, int $page = 1, array $optional = []): Response + { + $response = $this->client->send('GET', '/virtual_terminal', [ + 'query' => [ + 'perPage' => $perPage, + 'page' => $page, + ...$optional + ] + ]); + + return new Response($response); + } + + /** + * Fetch Virtual Terminal + * Get the details of a Virtual Terminal + * + * @param string $code The Virtual Terminal code + * @return Response + */ + public function fetch(string $code): Response + { + $response = $this->client->send('GET', "/virtual_terminal/{$code}"); + + return new Response($response); + } + + /** + * Update Virtual Terminal + * Update the details of a Virtual Terminal + * + * @param string $code The Virtual Terminal code + * @param array $data + * @return Response + */ + public function update(string $code, array $data): Response + { + $response = $this->client->send('PUT', "/virtual_terminal/{$code}", [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * Deactivate Virtual Terminal + * Deactivate a Virtual Terminal on your integration + * + * @param string $code The Virtual Terminal code + * @return Response + */ + public function deactivate(string $code): Response + { + $response = $this->client->send('PUT', "/virtual_terminal/{$code}/deactivate"); + + return new Response($response); + } + + /** + * Assign Destination + * Add a destination (e.g. WhatsApp) to a Virtual Terminal + * + * @param string $code The Virtual Terminal code + * @param array $data + * @return Response + */ + public function assignDestination(string $code, array $data): Response + { + $response = $this->client->send('POST', "/virtual_terminal/{$code}/destination/assign", [ + 'json' => $data + ]); + + return new Response($response); + } + + /** + * Unassign Destination + * Remove a destination from a Virtual Terminal + * + * @param string $code The Virtual Terminal code + * @return Response + */ + public function unassignDestination(string $code): Response + { + $response = $this->client->send('POST', "/virtual_terminal/{$code}/destination/unassign"); + + return new Response($response); + } + + /** + * Add Split Code + * Add a split code to a Virtual Terminal + * + * @param string $code The Virtual Terminal code + * @param string $splitCode The split code to add + * @return Response + */ + public function addSplitCode(string $code, string $splitCode): Response + { + $response = $this->client->send('PUT', "/virtual_terminal/{$code}/split_code", [ + 'json' => [ + 'split_code' => $splitCode + ] + ]); + + return new Response($response); + } + + /** + * Remove Split Code + * Remove a split code from a Virtual Terminal + * + * @param string $code The Virtual Terminal code + * @return Response + */ + public function removeSplitCode(string $code): Response + { + $response = $this->client->send('DELETE', "/virtual_terminal/{$code}/split_code"); + + return new Response($response); + } +} diff --git a/src/Traits/HasMetadata.php b/src/Traits/HasMetadata.php index 62872f5..ab0c211 100644 --- a/src/Traits/HasMetadata.php +++ b/src/Traits/HasMetadata.php @@ -20,12 +20,12 @@ trait HasMetadata */ private function resolveMetadata(mixed $metadata): void { - if (is_null($metadata)) { + if ($metadata === null) { $this->metadata = null; return; } - if (is_string($metadata)) { + if (\is_string($metadata)) { $decoded = json_decode($metadata, true); if (json_last_error() === JSON_ERROR_NONE) { @@ -35,7 +35,7 @@ private function resolveMetadata(mixed $metadata): void } } - $this->metadata = is_array($metadata) ? $metadata : null; + $this->metadata = \is_array($metadata) ? $metadata : null; } /** diff --git a/src/Webhook.php b/src/Webhook.php index 7e90ed9..c40f962 100644 --- a/src/Webhook.php +++ b/src/Webhook.php @@ -6,6 +6,12 @@ use Faridibin\Paystack\Exceptions\PaystackException; +/** + * Utility class for validating Paystack webhook requests. + * + * Provides HMAC-SHA512 signature validation and Paystack IP whitelisting. + * Chain {@see validateSignature()} and {@see isIpWhitelisted()} for full validation. + */ class Webhook { /** @@ -19,10 +25,12 @@ class Webhook /** * Validate the webhook signature. - * + * * @param string $payload * @param string $signature * @param string $secretKey + * @return self + * @throws PaystackException */ public static function validateSignature(string $payload, string $signature, string $secretKey): self { @@ -41,7 +49,10 @@ public static function validateSignature(string $payload, string $signature, str /** * Check if the IP is whitelisted. + * * @param string $ip + * @return self + * @throws PaystackException */ public static function isIpWhitelisted(string $ip): self { diff --git a/tests/Fixtures.php b/tests/Fixtures.php new file mode 100644 index 0000000..68f4a85 --- /dev/null +++ b/tests/Fixtures.php @@ -0,0 +1,792 @@ + true, + 'message' => 'Authorization URL created', + 'data' => [ + 'authorization_url' => 'https://checkout.paystack.com/0peioxfhpn', + 'access_code' => '0peioxfhpn', + 'reference' => '7PVGX8MEk85tgeEpVDtD', + ], + ]; + } + + public static function transaction(): array + { + return [ + 'status' => true, + 'message' => 'Verification successful', + 'data' => [ + 'id' => 292584142, + 'domain' => 'test', + 'status' => 'success', + 'reference' => '7PVGX8MEk85tgeEpVDtD', + 'receipt_number' => null, + 'amount' => 10000, + 'message' => null, + 'gateway_response' => 'Successful', + 'paid_at' => '2019-10-09T13:03:28.000Z', + 'created_at' => '2019-10-09T13:00:08.000Z', + 'channel' => 'card', + 'currency' => 'NGN', + 'ip_address' => '1.2.3.4', + 'fees' => 150, + 'requested_amount' => 10000, + 'authorization' => [ + 'authorization_code' => 'AUTH_8dfhjjdt', + 'bin' => '408408', + 'last4' => '4081', + 'exp_month' => '12', + 'exp_year' => '2030', + 'channel' => 'card', + 'card_type' => 'visa DEBIT', + 'bank' => 'Test Bank', + 'country_code' => 'NG', + 'brand' => 'visa', + 'reusable' => true, + 'signature' => 'SIG_JrPHkDKCkJXKFWhf', + 'account_name' => null, + ], + 'customer' => [ + 'id' => 84312219, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'customer_code' => 'CUS_xnxdt6s1zxo8hec', + 'phone' => null, + 'risk_action' => 'default', + ], + 'plan' => null, + 'split' => [], + 'subaccount' => [], + ], + ]; + } + + public static function transactionList(): array + { + return [ + 'status' => true, + 'message' => 'Transactions retrieved', + 'data' => [self::transaction()['data']], + 'meta' => [ + 'total' => 1, + 'skipped' => 0, + 'perPage' => 50, + 'page' => 1, + 'pageCount' => 1, + ], + ]; + } + + // ------------------------------------------------------------------------- + // Customers + // ------------------------------------------------------------------------- + + public static function customer(): array + { + return [ + 'status' => true, + 'message' => 'Customer created', + 'data' => [ + 'id' => 84312219, + 'integration' => 100032, + 'domain' => 'test', + 'customer_code' => 'CUS_xnxdt6s1zxo8hec', + 'email' => 'john@example.com', + 'first_name' => 'John', + 'last_name' => 'Doe', + 'phone' => null, + 'identified' => false, + 'identifications' => null, + 'risk_action' => 'default', + 'createdAt' => '2016-03-29T20:03:09.000Z', + 'updatedAt' => '2016-03-29T20:03:09.000Z', + ], + ]; + } + + // ------------------------------------------------------------------------- + // Plans + // ------------------------------------------------------------------------- + + public static function plan(): array + { + return [ + 'status' => true, + 'message' => 'Plan created', + 'data' => [ + 'id' => 28, + 'integration' => 100032, + 'domain' => 'test', + 'name' => 'Monthly Retainer', + 'plan_code' => 'PLN_gx2wn373dvkern4', + 'description' => null, + 'amount' => 50000, + 'interval' => 'monthly', + 'invoice_limit' => 0, + 'send_invoices' => true, + 'send_sms' => true, + 'currency' => 'NGN', + 'hosted_page' => false, + 'migrate' => false, + 'is_archived' => false, + 'createdAt' => '2016-03-29T22:42:50.000Z', + 'updatedAt' => '2016-03-29T22:42:50.000Z', + ], + ]; + } + + public static function planList(): array + { + return [ + 'status' => true, + 'message' => 'Plans retrieved', + 'data' => [self::plan()['data']], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Subscriptions + // ------------------------------------------------------------------------- + + public static function subscription(): array + { + return [ + 'status' => true, + 'message' => 'Subscription successfully created', + 'data' => [ + 'id' => 9, + 'domain' => 'test', + 'integration' => 100032, + 'subscription_code' => 'SUB_vsyqdmlillxqlm8', + 'email_token' => 'd7gofp6yppn3qz7', + 'amount' => 50000, + 'quantity' => 1, + 'status' => 'active', + 'start' => 1459296064, + 'cron_expression' => '0 0 28 * *', + 'next_payment_date' => '2016-04-28T07:00:00.000Z', + 'createdAt' => '2016-03-29T22:01:04.000Z', + 'updatedAt' => '2016-03-29T22:01:04.000Z', + 'customer' => 23, + 'plan' => 28, + 'authorization' => [], + ], + ]; + } + + public static function subscriptionList(): array + { + return [ + 'status' => true, + 'message' => 'Subscriptions retrieved', + 'data' => [self::subscription()['data']], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Transfer Recipients + // ------------------------------------------------------------------------- + + public static function recipient(): array + { + return [ + 'status' => true, + 'message' => 'Transfer recipient created successfully', + 'data' => [ + 'id' => 55, + 'integration' => 100073, + 'domain' => 'test', + 'type' => 'nuban', + 'name' => 'John Doe', + 'recipient_code' => 'RCP_t0ya41mp35flk40', + 'active' => true, + 'currency' => 'NGN', + 'is_deleted' => false, + 'details' => [ + 'account_number' => '0100000010', + 'account_name' => 'John Doe', + 'bank_code' => '044', + 'bank_name' => 'Access Bank', + ], + 'createdAt' => '2016-10-01T10:59:52.000Z', + 'updatedAt' => '2016-10-01T10:59:52.000Z', + ], + ]; + } + + public static function recipientList(): array + { + return [ + 'status' => true, + 'message' => 'Transfer recipients retrieved', + 'data' => [self::recipient()['data']], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Transfers + // ------------------------------------------------------------------------- + + public static function transfer(): array + { + return [ + 'status' => true, + 'message' => 'Transfer requires OTP to continue', + 'data' => [ + 'id' => 14, + 'integration' => 100073, + 'domain' => 'test', + 'amount' => 5000, + 'currency' => 'NGN', + 'source' => 'balance', + 'reason' => 'Salary payment', + 'recipient' => 55, + 'status' => 'otp', + 'transfer_code' => 'TRF_1ptvuv321ahaa7q', + 'createdAt' => '2017-02-02T19:39:04.000Z', + 'updatedAt' => '2017-02-02T19:39:04.000Z', + ], + ]; + } + + public static function transferList(): array + { + return [ + 'status' => true, + 'message' => 'Transfers retrieved', + 'data' => [self::transfer()['data']], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Balance + // ------------------------------------------------------------------------- + + public static function balance(): array + { + return [ + 'status' => true, + 'message' => 'Balances retrieved', + 'data' => [ + ['currency' => 'NGN', 'balance' => 150700], + ], + ]; + } + + // ------------------------------------------------------------------------- + // Banks + // ------------------------------------------------------------------------- + + public static function bankList(): array + { + return [ + 'status' => true, + 'message' => 'Banks retrieved', + 'data' => [ + [ + 'id' => 1, + 'name' => 'Access Bank', + 'slug' => 'access-bank', + 'code' => '044', + 'longcode' => '044150149', + 'gateway' => null, + 'pay_with_bank' => false, + 'supports_transfer' => true, + 'available_for_direct_debit' => false, + 'active' => true, + 'is_deleted' => false, + 'country' => 'Nigeria', + 'currency' => 'NGN', + 'type' => 'nuban', + 'createdAt' => '2016-07-14T10:04:29.000Z', + 'updatedAt' => '2020-02-18T08:06:44.000Z', + ], + ], + 'meta' => ['next' => null, 'previous' => null, 'perPage' => 50], + ]; + } + + // ------------------------------------------------------------------------- + // Countries + // ------------------------------------------------------------------------- + + public static function countryList(): array + { + return [ + 'status' => true, + 'message' => 'Countries retrieved', + 'data' => [ + [ + 'id' => 1, + 'name' => 'Nigeria', + 'iso_code' => 'NG', + 'default_currency_code' => 'NGN', + 'calling_code' => '+234', + 'pilot_mode' => false, + 'can_go_live_automatically' => true, + 'active_for_dashboard_onboarding' => true, + 'integration_defaults' => [], + 'relationships' => [ + 'currency' => ['type' => 'currency', 'data' => ['NGN']], + 'integration_feature' => ['type' => 'integration_feature', 'data' => []], + 'integration_type' => ['type' => 'integration_type', 'data' => ['ITYPE_payment_processing']], + 'payment_method' => ['type' => 'payment_method', 'data' => ['card']], + ], + ], + ], + ]; + } + + // ------------------------------------------------------------------------- + // Dedicated Account + // ------------------------------------------------------------------------- + + public static function dedicatedAccount(): array + { + return [ + 'status' => true, + 'message' => 'NUBAN successfully created', + 'data' => [ + 'id' => 253, + 'integration' => 100073, + 'domain' => 'test', + 'account_name' => 'PAYSTACK-JOHN DOE', + 'account_number' => '9930020212', + 'assigned' => true, + 'currency' => 'NGN', + 'active' => true, + 'bank' => [ + 'name' => 'Wema Bank', + 'id' => 20, + 'slug' => 'wema-bank', + ], + 'assignment' => [ + 'id' => 1, + 'integration' => 100073, + 'domain' => 'test', + 'account_id' => 253, + 'assigned_at' => '2019-12-12T12:39:04.000Z', + 'expired' => false, + 'account_type' => 'PAY-WITH-TRANSFER-RECURRING', + 'customer' => [ + 'id' => 1530104, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'customer_code' => 'CUS_xnxdt6s1zxo8hec', + 'phone' => null, + 'risk_action' => 'default', + ], + ], + 'split_config' => [], + 'created_at' => '2019-12-12T12:39:04.000Z', + 'updated_at' => '2019-12-12T12:39:04.000Z', + ], + ]; + } + + public static function dedicatedAccountList(): array + { + return [ + 'status' => true, + 'message' => 'Dedicated accounts retrieved', + 'data' => [self::dedicatedAccount()['data']], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Subaccounts + // ------------------------------------------------------------------------- + + public static function subaccount(): array + { + return [ + 'status' => true, + 'message' => 'Subaccount created', + 'data' => [ + 'id' => 55, + 'integration' => 100973, + 'domain' => 'test', + 'subaccount_code' => 'ACCT_4hl4xenwpjy5wb2', + 'business_name' => 'Sunshine Studios', + 'description' => null, + 'primary_contact_name' => null, + 'primary_contact_email' => null, + 'primary_contact_phone' => null, + 'percentage_charge' => 18, + 'settlement_bank' => 'Access Bank', + 'account_number' => '0193274682', + 'settlement_schedule' => 'AUTO', + 'currency' => 'NGN', + 'active' => true, + 'is_verified' => false, + 'migrate' => false, + 'createdAt' => '2016-10-05T13:22:04.000Z', + 'updatedAt' => '2016-10-21T02:19:47.000Z', + ], + ]; + } + + public static function subaccountList(): array + { + return [ + 'status' => true, + 'message' => 'Subaccounts retrieved', + 'data' => [self::subaccount()['data']], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Settlements + // ------------------------------------------------------------------------- + + public static function settlementList(): array + { + return [ + 'status' => true, + 'message' => 'Settlements retrieved', + 'data' => [ + [ + 'id' => 1, + 'domain' => 'live', + 'status' => 'success', + 'currency' => 'NGN', + 'integration' => 100073, + 'total_amount' => 1000000, + 'effective_amount' => 1000000, + 'total_fees' => 0, + 'total_manual_adjustments' => 0, + 'settled_by' => null, + 'settled_at' => '2020-01-20T00:00:00.000Z', + 'created_at' => '2020-01-19T00:00:00.000Z', + 'updated_at' => '2020-01-20T00:00:00.000Z', + ], + ], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Disputes + // ------------------------------------------------------------------------- + + public static function disputeList(): array + { + return [ + 'status' => true, + 'message' => 'Disputes retrieved', + 'data' => [ + [ + 'id' => 1, + 'domain' => 'test', + 'currency' => 'NGN', + 'status' => 'awaiting-bank-feedback', + 'resolution' => null, + 'refund_amount' => null, + 'transaction' => [ + 'id' => 5991491, + 'domain' => 'test', + 'status' => 'success', + 'reference' => 'T685312322670591', + 'amount' => 10000, + 'currency' => 'NGN', + 'gateway_response' => 'Approved', + 'paid_at' => '2019-07-11T16:00:10.000Z', + 'created_at' => '2019-07-11T15:59:57.000Z', + 'channel' => 'card', + ], + 'customer' => [ + 'id' => 1530104, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'customer_code' => 'CUS_xnxdt6s1zxo8hec', + ], + 'created_at' => '2019-07-31T06:49:52.000Z', + 'updated_at' => '2019-07-31T07:10:26.000Z', + ], + ], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Refunds + // ------------------------------------------------------------------------- + + public static function refund(): array + { + return [ + 'status' => true, + 'message' => 'Refund created', + 'data' => [ + 'id' => 3018572, + 'integration' => 100073, + 'domain' => 'live', + 'transaction' => 1004723697, + 'currency' => 'NGN', + 'amount' => 10000, + 'deducted_amount' => 0, + 'status' => 'pending', + 'refunded_by' => 'john@example.com', + 'merchant_note' => 'Refund for T685312322670591', + 'customer_note' => 'Refund for T685312322670591', + 'expected_at' => '2020-10-27T15:47:38.000Z', + 'createdAt' => '2020-10-20T15:47:38.000Z', + 'updatedAt' => '2020-10-20T15:47:38.000Z', + ], + ]; + } + + // ------------------------------------------------------------------------- + // Payment Requests + // ------------------------------------------------------------------------- + + public static function paymentRequest(): array + { + return [ + 'status' => true, + 'message' => 'Payment request created', + 'data' => [ + 'id' => 3136406, + 'domain' => 'test', + 'integration' => 100032, + 'amount' => 42000, + 'currency' => 'NGN', + 'due_date' => null, + 'has_invoice' => true, + 'invoice_number' => 1, + 'description' => 'A test invoice', + 'pdf_url' => null, + 'line_items' => [], + 'tax' => [], + 'request_code' => 'PRQ_1weqqsn2wwzgft4', + 'status' => 'pending', + 'paid' => false, + 'paid_at' => null, + 'metadata' => null, + 'notifications' => [], + 'offline_reference' => '4286263136406', + 'customer' => [ + 'id' => 25833615, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'customer_code' => 'CUS_xnxdt6s1zxo8hec', + 'phone' => null, + ], + 'created_at' => '2020-06-29T16:10:01.000Z', + ], + ]; + } + + // ------------------------------------------------------------------------- + // Products + // ------------------------------------------------------------------------- + + public static function product(): array + { + return [ + 'status' => true, + 'message' => 'Product successfully created', + 'data' => [ + 'id' => 526, + 'integration' => 343288, + 'domain' => 'test', + 'name' => 'T-shirt', + 'description' => 'A nice shirt', + 'product_code' => 'PROD_hb8o42zl4vzahze', + 'slug' => 'tshirt-y4a4e9', + 'currency' => 'NGN', + 'price' => 2500, + 'quantity' => 100, + 'quantity_sold' => null, + 'is_shippable' => false, + 'unlimited' => false, + 'active' => true, + 'in_stock' => true, + 'has_variants' => false, + 'createdAt' => '2019-06-29T16:10:01.000Z', + 'updatedAt' => '2019-06-29T16:10:01.000Z', + ], + ]; + } + + public static function productList(): array + { + return [ + 'status' => true, + 'message' => 'Products retrieved', + 'data' => [self::product()['data']], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Payment Pages + // ------------------------------------------------------------------------- + + public static function paymentPage(): array + { + return [ + 'status' => true, + 'message' => 'Page created', + 'data' => [ + 'id' => 12, + 'integration' => 100032, + 'domain' => 'test', + 'name' => 'My Payment Page', + 'slug' => '5nApBwZkvY', + 'description' => null, + 'currency' => 'NGN', + 'type' => 'payment', + 'collect_phone' => false, + 'active' => true, + 'published' => true, + 'migrate' => null, + 'split_code' => null, + 'notification_email' => null, + 'metadata' => '{}', + 'createdAt' => '2016-09-09T19:18:37.000Z', + 'updatedAt' => '2016-09-09T19:18:37.000Z', + ], + ]; + } + + public static function paymentPageList(): array + { + return [ + 'status' => true, + 'message' => 'Pages retrieved', + 'data' => [self::paymentPage()['data']], + 'meta' => ['total' => 1, 'skipped' => 0, 'perPage' => 50, 'page' => 1, 'pageCount' => 1], + ]; + } + + // ------------------------------------------------------------------------- + // Charge + // ------------------------------------------------------------------------- + + public static function charge(): array + { + return [ + 'status' => true, + 'message' => 'Charge attempted', + 'data' => [ + 'reference' => 'ref_abc123', + 'status' => 'send_pin', + 'display_text' => 'Please enter your card PIN.', + 'amount' => 10000, + 'currency' => 'NGN', + 'transaction' => 292584142, + ], + ]; + } + + // ------------------------------------------------------------------------- + // Bulk Charges + // ------------------------------------------------------------------------- + + public static function bulkChargeBatch(): array + { + return [ + 'status' => true, + 'message' => 'Charges have been queued', + 'data' => [ + 'id' => 1, + 'domain' => 'test', + 'batch_code' => 'BCH_180tl7oq7cayggh', + 'status' => 'active', + 'total_charges' => 2, + 'pending_charges' => 2, + 'createdAt' => '2018-08-23T10:34:08.000Z', + 'updatedAt' => '2018-08-23T10:34:08.000Z', + ], + ]; + } + + // ------------------------------------------------------------------------- + // Integration + // ------------------------------------------------------------------------- + + public static function timeout(): array + { + return [ + 'status' => true, + 'message' => 'Payment session timeout retrieved', + 'data' => [ + 'payment_session_timeout' => 30, + ], + ]; + } + + // ------------------------------------------------------------------------- + // Verification + // ------------------------------------------------------------------------- + + public static function resolvedAccount(): array + { + return [ + 'status' => true, + 'message' => 'Account number resolved', + 'data' => [ + 'account_number' => '0123456789', + 'account_name' => 'John Doe', + 'bank_id' => 9, + ], + ]; + } + + public static function resolvedCardBin(): array + { + return [ + 'status' => true, + 'message' => 'Bin resolved', + 'data' => [ + 'bin' => '539983', + 'brand' => 'Mastercard', + 'sub_brand' => '', + 'country_code' => 'NG', + 'country_name' => 'Nigeria', + 'card_type' => 'DEBIT', + 'bank' => 'Guaranty Trust Bank', + 'linked_bank_id' => 9, + ], + ]; + } + + // ------------------------------------------------------------------------- + // Generic success (used where the endpoint returns no meaningful data body) + // ------------------------------------------------------------------------- + + public static function success(string $message = 'Request successful'): array + { + return [ + 'status' => true, + 'message' => $message, + 'data' => true, + ]; + } +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..1f8f9db --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in('Unit'); diff --git a/tests/SpecEndpoints.php b/tests/SpecEndpoints.php new file mode 100644 index 0000000..a20870c --- /dev/null +++ b/tests/SpecEndpoints.php @@ -0,0 +1,177 @@ + HandlerStack::create(new MockHandler([$queued]))]); + + $client = new Client('sk_test_abc'); + + $ref = new ReflectionProperty(Client::class, 'client'); + $ref->setAccessible(true); + $ref->setValue($client, $guzzle); + + return $client; +} + +function clientException(int $status, array $body): ClientException +{ + return new ClientException( + 'HTTP ' . $status, + new Request('GET', '/x'), + new Psr7Response($status, [], json_encode($body)) + ); +} + +it('decodes a successful JSON response into an array', function () { + $client = clientWith(new Psr7Response(200, [], json_encode(['status' => true, 'message' => 'OK']))); + + $result = $client->send('GET', '/balance'); + + expect($result)->toBe(['status' => true, 'message' => 'OK']); +}); + +it('maps a 401 to an AuthenticationException', function () { + $client = clientWith(clientException(401, ['message' => 'Invalid key'])); + + expect($client->send('GET', '/balance'))->toBeInstanceOf(AuthenticationException::class); +}); + +it('maps a 429 to a RateLimitException', function () { + $client = clientWith(clientException(429, ['message' => 'Slow down'])); + + expect($client->send('GET', '/balance'))->toBeInstanceOf(RateLimitException::class); +}); + +it('maps other 4xx to an ApiException carrying the message and code', function () { + $result = clientWith(clientException(404, ['message' => 'Not found']))->send('GET', '/balance'); + + expect($result)->toBeInstanceOf(ApiException::class) + ->and($result->getMessage())->toBe('Not found') + ->and($result->getCode())->toBe(404); +}); + +it('maps a 5xx ServerException to a generic unavailable PaystackException', function () { + $server = new ServerException( + 'Server error', + new Request('GET', '/x'), + new Psr7Response(500, [], json_encode(['message' => 'boom'])) + ); + + $result = clientWith($server)->send('GET', '/balance'); + + expect($result)->toBeInstanceOf(PaystackException::class) + ->and($result->getMessage())->toBe('Paystack is currently unavailable') + ->and($result->getCode())->toBe(500); +}); + +it('maps a transport-level GuzzleException to a PaystackException', function () { + $connect = new ConnectException('Connection refused', new Request('GET', '/x')); + + $result = clientWith($connect)->send('GET', '/balance'); + + expect($result)->toBeInstanceOf(PaystackException::class) + ->and($result->getMessage())->toBe('Connection refused'); +}); + +it('exposes the secret key', function () { + expect((new Client('sk_test_xyz'))->getSecretKey())->toBe('sk_test_xyz'); +}); diff --git a/tests/Unit/DataTransferObjects/CollectionTest.php b/tests/Unit/DataTransferObjects/CollectionTest.php new file mode 100644 index 0000000..690beb3 --- /dev/null +++ b/tests/Unit/DataTransferObjects/CollectionTest.php @@ -0,0 +1,44 @@ + 1, 'name' => 'Basic', 'currency' => 'NGN', 'interval' => 'monthly'], + ['id' => 2, 'name' => 'Pro', 'currency' => 'NGN', 'interval' => 'annually'], + ], PlanDTO::class); + + expect($collection->get())->toHaveCount(2) + ->and($collection->get()[0])->toBeInstanceOf(PlanDTO::class) + ->and($collection->get()[0]->name)->toBe('Basic') + ->and($collection->get()[1]->id)->toBe(2); +}); + +it('keeps items as-is when no DTO class is given', function () { + $rows = [['a' => 1], ['b' => 2]]; + + expect((new Collection($rows))->get())->toBe($rows); +}); + +it('keeps items as-is when the DTO class does not exist', function () { + $rows = [['a' => 1]]; + + expect((new Collection($rows, 'Nope\\DoesNotExist'))->get())->toBe($rows); +}); + +it('serializes hydrated DTOs back to arrays', function () { + $collection = new Collection([ + ['id' => 1, 'name' => 'Basic', 'currency' => 'NGN', 'interval' => 'monthly'], + ], PlanDTO::class); + + $array = $collection->toArray(); + + expect($array)->toHaveCount(1) + ->and($array[0]['name'])->toBe('Basic') + ->and($array[0]['currency'])->toBe('NGN'); +}); + +it('handles an empty collection', function () { + expect((new Collection([], PlanDTO::class))->get())->toBe([]); +}); diff --git a/tests/Unit/DataTransferObjects/GenericTest.php b/tests/Unit/DataTransferObjects/GenericTest.php new file mode 100644 index 0000000..a2bf344 --- /dev/null +++ b/tests/Unit/DataTransferObjects/GenericTest.php @@ -0,0 +1,45 @@ + 'John', 'age' => 30]); + + expect($g->name)->toBe('John') + ->and($g->age)->toBe(30); +}); + +it('returns null for unknown attributes', function () { + $g = new Generic(['name' => 'John']); + + expect($g->missing)->toBeNull(); +}); + +it('reports presence with isset', function () { + $g = new Generic(['name' => 'John', 'empty' => null]); + + expect(isset($g->name))->toBeTrue() + ->and(isset($g->missing))->toBeFalse() + ->and(isset($g->empty))->toBeFalse(); +}); + +it('recursively wraps nested arrays in Generic', function () { + $g = new Generic(['customer' => ['email' => 'a@b.com', 'meta' => ['tier' => 'gold']]]); + + expect($g->customer)->toBeInstanceOf(Generic::class) + ->and($g->customer->email)->toBe('a@b.com') + ->and($g->customer->meta)->toBeInstanceOf(Generic::class) + ->and($g->customer->meta->tier)->toBe('gold'); +}); + +it('round-trips to a nested array via toArray', function () { + $data = ['id' => 1, 'customer' => ['email' => 'a@b.com']]; + + expect((new Generic($data))->toArray())->toBe($data); +}); + +it('exposes the raw attribute map', function () { + $g = new Generic(['a' => 1, 'b' => 2]); + + expect($g->getAttributes())->toHaveKeys(['a', 'b']); +}); diff --git a/tests/Unit/DataTransferObjects/NullablePropertiesTest.php b/tests/Unit/DataTransferObjects/NullablePropertiesTest.php new file mode 100644 index 0000000..d533d75 --- /dev/null +++ b/tests/Unit/DataTransferObjects/NullablePropertiesTest.php @@ -0,0 +1,133 @@ +currency)->toBeNull() + ->and($dto->interval)->toBeNull() + ->and($dto->createdAt)->toBeNull() + ->and($dto->updatedAt)->toBeNull() + ->and($dto->toArray())->toBeArray(); +}); + +it('SubscriberDTO leaves currency and status null when absent', function () { + $dto = new SubscriberDTO(customer_email: 'a@b.com'); + + expect($dto->currency)->toBeNull() + ->and($dto->status)->toBeNull() + ->and($dto->toArray())->toBeArray(); +}); + +it('SubscriptionDTO leaves dates null and defaults status to UNKNOWN when absent', function () { + $dto = new SubscriptionDTO(subscription_code: 'SUB_x'); + + expect($dto->createdAt)->toBeNull() + ->and($dto->updatedAt)->toBeNull() + ->and($dto->next_payment_date)->toBeNull() + ->and($dto->start)->toBeNull() + ->and($dto->cancelledAt)->toBeNull() + ->and($dto->status)->toBe(Status::UNKNOWN); +}); + +it('SubaccountDTO leaves currency and dates null when absent', function () { + $dto = new SubaccountDTO(business_name: 'Acme'); + + expect($dto->currency)->toBeNull() + ->and($dto->createdAt)->toBeNull() + ->and($dto->updatedAt)->toBeNull(); +}); + +it('PageDTO leaves type, currency and dates null when absent', function () { + $dto = new PageDTO(name: 'Page'); + + expect($dto->type)->toBeNull() + ->and($dto->currency)->toBeNull() + ->and($dto->createdAt)->toBeNull() + ->and($dto->updatedAt)->toBeNull(); +}); + +it('TransactionDTO leaves enums and dates null when absent', function () { + $dto = new TransactionDTO(reference: 'ref'); + + expect($dto->currency)->toBeNull() + ->and($dto->status)->toBeNull() + ->and($dto->channel)->toBeNull() + ->and($dto->createdAt)->toBeNull() + ->and($dto->paidAt)->toBeNull() + ->and($dto->transactionDate)->toBeNull(); +}); + +it('AuthorizationDTO leaves channel, exp_month and reusable null when absent', function () { + $dto = new AuthorizationDTO(authorization_code: 'AUTH_x'); + + expect($dto->channel)->toBeNull() + ->and($dto->exp_month)->toBeNull() + ->and($dto->reusable)->toBeNull(); +}); + +it('InvoiceDTO leaves enums and dates null when absent', function () { + $dto = new InvoiceDTO(invoice_code: 'INV_x'); + + expect($dto->currency)->toBeNull() + ->and($dto->channel)->toBeNull() + ->and($dto->status)->toBeNull() + ->and($dto->createdAt)->toBeNull() + ->and($dto->periodStart)->toBeNull() + ->and($dto->nextNotification)->toBeNull(); +}); + +it('CustomerDTO leaves dates null when absent', function () { + $dto = new CustomerDTO(email: 'a@b.com'); + + expect($dto->createdAt)->toBeNull() + ->and($dto->updatedAt)->toBeNull(); +}); + +it('RecipientDTO leaves type, currency and dates null when absent', function () { + $dto = new RecipientDTO(name: 'John'); + + expect($dto->type)->toBeNull() + ->and($dto->currency)->toBeNull() + ->and($dto->createdAt)->toBeNull() + ->and($dto->updatedAt)->toBeNull(); +}); + +it('LogDTO leaves start_time null when absent and getEndTime returns null', function () { + $dto = new LogDTO(attempts: 1); + + expect($dto->start_time)->toBeNull() + ->and($dto->getEndTime())->toBeNull(); +}); + +it('still resolves enums and dates when the fields ARE present', function () { + $dto = new PlanDTO(name: 'Full', currency: 'NGN', interval: 'monthly', createdAt: '2020-01-01T00:00:00Z'); + + expect($dto->currency?->value)->toBe('NGN') + ->and($dto->interval?->value)->toBe('monthly') + ->and($dto->createdAt)->toBeInstanceOf(DateTime::class); +}); diff --git a/tests/Unit/DataTransferObjects/PlanDTOTest.php b/tests/Unit/DataTransferObjects/PlanDTOTest.php new file mode 100644 index 0000000..d8428ca --- /dev/null +++ b/tests/Unit/DataTransferObjects/PlanDTOTest.php @@ -0,0 +1,64 @@ +id)->toBe(28) + ->and($dto->name)->toBe('Monthly Retainer') + ->and($dto->plan_code)->toBe('PLN_gx2wn373dvkern4') + ->and($dto->amount)->toBe(50000); +}); + +it('coerces string currency and interval into enums', function () { + $dto = new PlanDTO(...Fixtures::plan()['data']); + + expect($dto->currency)->toBe(Currency::NGN) + ->and($dto->interval)->toBe(Interval::MONTHLY); +}); + +it('parses ISO date strings into DateTime', function () { + $dto = new PlanDTO(...Fixtures::plan()['data']); + + expect($dto->createdAt)->toBeInstanceOf(DateTime::class) + ->and($dto->createdAt->format('Y-m-d'))->toBe('2016-03-29'); +}); + +it('normalizes integer flags into booleans', function () { + $data = ['name' => 'X', 'currency' => 'NGN', 'interval' => 'monthly', 'send_invoices' => 1, 'send_sms' => 0]; + + $dto = new PlanDTO(...$data); + + expect($dto->send_invoices)->toBeTrue() + ->and($dto->send_sms)->toBeFalse(); +}); + +it('wraps nested relations in typed collections', function () { + $dto = new PlanDTO(...Fixtures::plan()['data']); + + expect($dto->pages)->toBeInstanceOf(Collection::class) + ->and($dto->subscriptions)->toBeInstanceOf(Collection::class) + ->and($dto->subscribers)->toBeInstanceOf(Collection::class); +}); + +it('absorbs unknown keys via the variadic catch-all without erroring', function () { + $data = Fixtures::plan()['data']; + $data['some_future_field'] = 'whatever'; + + $dto = new PlanDTO(...$data); + + expect($dto->name)->toBe('Monthly Retainer'); +}); + +it('serializes back to an array with enums and dates flattened', function () { + $array = (new PlanDTO(...Fixtures::plan()['data']))->toArray(); + + expect($array['currency'])->toBe('NGN') + ->and($array['interval'])->toBe('monthly') + ->and($array['createdAt'])->toBe('2016-03-29 22:42:50') + ->and($array)->not->toHaveKey('description'); // null values are omitted +}); diff --git a/tests/Unit/DataTransferObjects/ResponseTest.php b/tests/Unit/DataTransferObjects/ResponseTest.php new file mode 100644 index 0000000..25cc6fe --- /dev/null +++ b/tests/Unit/DataTransferObjects/ResponseTest.php @@ -0,0 +1,100 @@ + true, 'message' => 'OK', 'code' => 201, 'data' => []]); + + expect($response->getStatus())->toBeTrue() + ->and($response->getMessage())->toBe('OK') + ->and($response->getStatusCode())->toBe(201); +}); + +it('defaults code to 200 and missing fields gracefully', function () { + $response = new Response(['data' => ['x' => 1]]); + + expect($response->getStatus())->toBeFalse() + ->and($response->getMessage())->toBe('') + ->and($response->getStatusCode())->toBe(200); +}); + +it('wraps untyped data in a Generic DTO', function () { + $response = new Response(['status' => true, 'data' => ['reference' => 'ref_1']]); + + expect($response->getData())->toBeInstanceOf(Generic::class) + ->and($response->getData()->reference)->toBe('ref_1'); +}); + +it('transforms data into the given DTO class', function () { + $response = new Response( + ['status' => true, 'data' => ['id' => 7, 'name' => 'Gold', 'currency' => 'NGN', 'interval' => 'monthly']], + PlanDTO::class + ); + + expect($response->getData())->toBeInstanceOf(PlanDTO::class) + ->and($response->getData()->id)->toBe(7) + ->and($response->getData()->name)->toBe('Gold'); +}); + +it('transforms a list into a typed Collection when isCollection is true', function () { + $response = new Response( + ['status' => true, 'data' => [ + ['id' => 1, 'name' => 'A', 'currency' => 'NGN', 'interval' => 'monthly'], + ['id' => 2, 'name' => 'B', 'currency' => 'NGN', 'interval' => 'monthly'], + ]], + PlanDTO::class, + true + ); + + expect($response->getData())->toBeInstanceOf(Collection::class) + ->and($response->getData()->get())->toHaveCount(2) + ->and($response->getData()->get()[0])->toBeInstanceOf(PlanDTO::class); +}); + +it('wraps meta in a Generic DTO', function () { + $response = new Response(['status' => true, 'data' => [], 'meta' => ['total' => 50, 'page' => 1]]); + + expect($response->getMeta())->toBeInstanceOf(Generic::class) + ->and($response->getMeta()->total)->toBe(50); +}); + +it('has null meta when absent', function () { + expect((new Response(['status' => true, 'data' => []]))->getMeta())->toBeNull(); +}); + +it('reads a key off the data DTO via get()', function () { + $response = new Response(['status' => true, 'data' => ['reference' => 'ref_9']]); + + expect($response->get('reference'))->toBe('ref_9') + ->and($response->get('missing', 'fallback'))->toBe('fallback'); +}); + +it('maps an exception into a failed response', function () { + $response = new Response(new PaystackException('Boom', 422)); + + expect($response->getStatus())->toBeFalse() + ->and($response->getStatusCode())->toBe(422) + ->and($response->getMessage())->toBe('Boom') + ->and($response->getData())->toBeInstanceOf(Generic::class) + ->and($response->getData()->error)->toBe('Boom'); +}); + +it('defaults exception status code to 500 when the exception has none', function () { + $response = new Response(new PaystackException('No code')); + + expect($response->getStatusCode())->toBe(500); +}); + +it('serializes back to an array including meta', function () { + $response = new Response(['status' => true, 'message' => 'OK', 'data' => ['a' => 1], 'meta' => ['total' => 3]]); + + $array = $response->toArray(); + + expect($array)->toMatchArray(['status' => true, 'message' => 'OK']) + ->and($array['data'])->toBe(['a' => 1]) + ->and($array['meta'])->toBe(['total' => 3]); +}); diff --git a/tests/Unit/DataTransferObjects/SpecFieldsTest.php b/tests/Unit/DataTransferObjects/SpecFieldsTest.php new file mode 100644 index 0000000..f6061c9 --- /dev/null +++ b/tests/Unit/DataTransferObjects/SpecFieldsTest.php @@ -0,0 +1,62 @@ + 'Item', 'amount' => 1000]], + tax: [['name' => 'VAT', 'amount' => 75]], + notifications: [['sent_at' => '2026-04-01']], + ); + + expect($dto->request_code)->toBe('PRQ_x') + ->and($dto->offline_reference)->toBe('OFF_1') + ->and($dto->pdf_url)->toBe('https://x/invoice.pdf') + ->and($dto->split_code)->toBe('SPL_x') + ->and($dto->due_date)->toBe('2026-05-01') + ->and($dto->invoice_number)->toBe(42) + ->and($dto->pending_amount)->toBe(1500) + ->and($dto->has_invoice)->toBeTrue() + ->and($dto->discount)->toBe(250) + ->and($dto->line_items)->toHaveCount(1) + ->and($dto->tax)->toHaveCount(1) + ->and($dto->notifications)->toHaveCount(1); +}); + +it('SubscriptionDTO captures metadata', function () { + $dto = new SubscriptionDTO(subscription_code: 'SUB_x', metadata: ['custom' => 'value']); + + expect($dto->metadata)->toBe(['custom' => 'value']); +}); + +it('RecipientDTO captures is_deleted in both Paystack spellings', function () { + expect((new RecipientDTO(name: 'A', is_deleted: true))->isDeleted)->toBeTrue() + ->and((new RecipientDTO(name: 'B', isDeleted: false))->isDeleted)->toBeFalse() + ->and((new RecipientDTO(name: 'C'))->isDeleted)->toBeNull(); +}); + +it('still absorbs genuinely unknown fields without crashing (safety net intact)', function () { + $dto = new InvoiceDTO(request_code: 'PRQ_x', some_field_paystack_adds_in_2030: 'surprise'); + + expect($dto->request_code)->toBe('PRQ_x'); +}); diff --git a/tests/Unit/PaystackTest.php b/tests/Unit/PaystackTest.php new file mode 100644 index 0000000..d574747 --- /dev/null +++ b/tests/Unit/PaystackTest.php @@ -0,0 +1,55 @@ +client = Mockery::mock(ClientInterface::class); + $this->paystack = new Paystack('sk_test_abc', $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('throws without a secret key', function () { + new Paystack(null); +})->throws(PaystackException::class, 'Secret key is required.'); + +it('resolves the health service', function () { + expect($this->paystack->health())->toBeInstanceOf(Health::class); +}); + +it('resolves a registered service', function () { + $this->paystack->registerService('transactions', Transactions::class, TransactionsInterface::class); + + expect($this->paystack->transactions())->toBeInstanceOf(Transactions::class); +}); + +it('caches service instances', function () { + $this->paystack->registerService('customers', Customers::class, CustomersInterface::class); + + $first = $this->paystack->customers(); + $second = $this->paystack->customers(); + + expect($first)->toBe($second); +}); + +it('throws for an unknown service', function () { + $this->paystack->unknown(); +})->throws(PaystackException::class, 'Service [unknown] not found.'); + +it('registers multiple services at once', function () { + $this->paystack->registerServices([ + 'transactions' => [Transactions::class, TransactionsInterface::class], + 'customers' => [Customers::class, CustomersInterface::class], + ]); + + expect($this->paystack->transactions())->toBeInstanceOf(Transactions::class) + ->and($this->paystack->customers())->toBeInstanceOf(Customers::class); +}); diff --git a/tests/Unit/Services/ApplePayTest.php b/tests/Unit/Services/ApplePayTest.php new file mode 100644 index 0000000..bb8a9be --- /dev/null +++ b/tests/Unit/Services/ApplePayTest.php @@ -0,0 +1,41 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new ApplePay(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('registers a domain for apple pay', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/apple-pay/domain', Mockery::on(fn($o) => $o['json']['domainName'] === 'example.com')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->registerDomain('example.com'); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists registered apple pay domains', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/apple-pay/domain', Mockery::any()) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->listDomains(); + expect($response->getStatus())->toBeTrue(); +}); + +it('unregisters a domain from apple pay', function () { + $this->client->shouldReceive('send') + ->once() + ->with('DELETE', '/apple-pay/domain', Mockery::on(fn($o) => $o['json']['domainName'] === 'example.com')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->unregisterDomain('example.com'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/BalanceTest.php b/tests/Unit/Services/BalanceTest.php new file mode 100644 index 0000000..592364d --- /dev/null +++ b/tests/Unit/Services/BalanceTest.php @@ -0,0 +1,22 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Balance(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('fetches the integration balance', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/balance') + ->andReturn(Fixtures::balance()); + + $response = $this->service->fetch(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Balances retrieved'); +}); diff --git a/tests/Unit/Services/BulkChargesTest.php b/tests/Unit/Services/BulkChargesTest.php new file mode 100644 index 0000000..7110637 --- /dev/null +++ b/tests/Unit/Services/BulkChargesTest.php @@ -0,0 +1,76 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new BulkCharges(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('initiates a bulk charge', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/bulkcharge', Mockery::any()) + ->andReturn(Fixtures::bulkChargeBatch()); + + $response = $this->service->initiateBulkCharge([['authorization' => 'AUTH_xxx', 'amount' => 500]]); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Charges have been queued'); +}); + +it('lists bulk charge batches', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/bulkcharge', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(['status' => true, 'message' => 'Bulk charges retrieved', 'data' => [Fixtures::bulkChargeBatch()['data']]]); + + $response = $this->service->listBulkChargeBatches(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Bulk charges retrieved'); +}); + +it('fetches a bulk charge batch', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/bulkcharge/BCH_xxx') + ->andReturn(Fixtures::bulkChargeBatch()); + + $response = $this->service->fetchBulkChargeBatch('BCH_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches charges in a batch filtered by status', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/bulkcharge/BCH_xxx/charges', Mockery::on(fn($o) => $o['query']['status'] === 'success')) + ->andReturn(['status' => true, 'message' => 'Charges retrieved', 'data' => []]); + + $response = $this->service->fetchChargesInBatch('BCH_xxx', 'success'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Charges retrieved'); +}); + +it('pauses a bulk charge batch', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/bulkcharge/pause/BCH_xxx') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->pauseBulkChargeBatch('BCH_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('resumes a bulk charge batch', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/bulkcharge/resume/BCH_xxx') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->resumeBulkChargeBatch('BCH_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/ChargeTest.php b/tests/Unit/Services/ChargeTest.php new file mode 100644 index 0000000..6b4373e --- /dev/null +++ b/tests/Unit/Services/ChargeTest.php @@ -0,0 +1,96 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Charge(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a charge with email and amount', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/charge', Mockery::on( + fn($o) => $o['json']['email'] === 'user@example.com' && $o['json']['amount'] === 10000 + )) + ->andReturn(Fixtures::charge()); + + $response = $this->service->create('user@example.com', 10000); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Charge attempted'); +}); + +it('submits a PIN', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/charge/submit_pin', Mockery::on( + fn($o) => $o['json']['pin'] === '1234' && $o['json']['reference'] === 'ref_xxx' + )) + ->andReturn(Fixtures::charge()); + + $response = $this->service->submitPin('1234', 'ref_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('submits an OTP', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/charge/submit_otp', Mockery::on( + fn($o) => $o['json']['otp'] === '123456' && $o['json']['reference'] === 'ref_xxx' + )) + ->andReturn(Fixtures::charge()); + + $response = $this->service->submitOtp('123456', 'ref_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('submits a phone number', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/charge/submit_phone', Mockery::on( + fn($o) => $o['json']['phone'] === '+2348000000000' + )) + ->andReturn(Fixtures::charge()); + + $response = $this->service->submitPhone('+2348000000000', 'ref_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('submits a birthday', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/charge/submit_birthday', Mockery::on( + fn($o) => $o['json']['birthday'] === '1990-01-15' + )) + ->andReturn(Fixtures::charge()); + + $response = $this->service->submitBirthday('1990-01-15', 'ref_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('submits an address', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/charge/submit_address', Mockery::on( + fn($o) => $o['json']['address'] === '1 Lagos St' + && $o['json']['city'] === 'Lagos' + && $o['json']['zip_code'] === '100001' + )) + ->andReturn(Fixtures::charge()); + + $response = $this->service->submitAddress('1 Lagos St', 'Lagos', 'Lagos', '100001', 'ref_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('checks a pending charge by reference', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/charge/ref_xxx') + ->andReturn(Fixtures::charge()); + + $response = $this->service->checkPendingCharge('ref_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/ControlTest.php b/tests/Unit/Services/ControlTest.php new file mode 100644 index 0000000..1026ccb --- /dev/null +++ b/tests/Unit/Services/ControlTest.php @@ -0,0 +1,74 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Control(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('checks the integration balance', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/balance') + ->andReturn(['status' => true, 'message' => 'Balances retrieved', 'data' => []]); + + $response = $this->service->checkBalance(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Balances retrieved'); +}); + +it('fetches the balance ledger', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/balance/ledger') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->fetchBalanceLedger(); + expect($response->getStatus())->toBeTrue(); +}); + +it('resends transfer OTP', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transfer/resend_otp', Mockery::on( + fn($o) => $o['json']['transfer_code'] === 'TRF_xxx' && $o['json']['reason'] === 'resend_otp' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->resendOtp('TRF_xxx', 'resend_otp'); + expect($response->getStatus())->toBeTrue(); +}); + +it('disables otp requirement', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transfer/disable_otp') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->disableOtp(); + expect($response->getStatus())->toBeTrue(); +}); + +it('finalizes disable otp with code', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transfer/disable_otp_finalize', Mockery::on(fn($o) => $o['json']['otp'] === '123456')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->finalizeDisableOtp('123456'); + expect($response->getStatus())->toBeTrue(); +}); + +it('enables otp requirement', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transfer/enable_otp') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->enableOtp(); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/CustomersTest.php b/tests/Unit/Services/CustomersTest.php new file mode 100644 index 0000000..88e4bba --- /dev/null +++ b/tests/Unit/Services/CustomersTest.php @@ -0,0 +1,117 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Customers(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a customer', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/customer', Mockery::any()) + ->andReturn(Fixtures::customer()); + + $response = $this->service->create(['email' => 'test@example.com']); + expect($response)->toBeInstanceOf(Response::class); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Customer created'); +}); + +it('fetches a customer by code', function () { + $this->client->shouldReceive('send')->once()->with('GET', '/customer/CUS_xxx')->andReturn(Fixtures::customer()); + + $response = $this->service->fetch('CUS_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a customer', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/customer/CUS_xxx', Mockery::any()) + ->andReturn(Fixtures::customer()); + + $response = $this->service->update('CUS_xxx', ['first_name' => 'Updated']); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists customers with pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/customer', Mockery::on(fn($o) => $o['query']['perPage'] === 10 && $o['query']['page'] === 1)) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->list(10, 1); + expect($response->getStatus())->toBeTrue(); +}); + +it('sets risk status with correct payload', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/customer/set_risk_action', Mockery::on( + fn($o) => $o['json']['customer'] === 'CUS_xxx' && $o['json']['risk_action'] === 'blacklist' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->setRiskStatus('CUS_xxx', 'blacklist'); + expect($response->getStatus())->toBeTrue(); +}); + +it('deactivates an authorization', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/customer/authorization/deactivate', Mockery::on( + fn($o) => $o['json']['authorization_code'] === 'AUTH_xxx' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->deactivateAuthorization('AUTH_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('initializes an authorization', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/customer/authorization/initialize', Mockery::on( + fn($o) => $o['json']['email'] === 'test@example.com' && $o['json']['channel'] === 'direct_debit' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->initializeAuthorization('test@example.com'); + expect($response->getStatus())->toBeTrue(); +}); + +it('verifies an authorization by reference', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/customer/authorization/verify/ref_123') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->verifyAuthorization('ref_123'); + expect($response->getStatus())->toBeTrue(); +}); + +it('initializes direct debit for a customer', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/customer/CUS_xxx/initialize-direct-debit', Mockery::any()) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->initializeDirectDebit('CUS_xxx', ['bank_code' => '057']); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches mandate authorizations for a customer', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/customer/CUS_xxx/directdebit-mandate-authorizations') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->fetchMandateAuthorizations('CUS_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/DedicatedAccountTest.php b/tests/Unit/Services/DedicatedAccountTest.php new file mode 100644 index 0000000..e1a952b --- /dev/null +++ b/tests/Unit/Services/DedicatedAccountTest.php @@ -0,0 +1,103 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new DedicatedAccount(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a dedicated account for a customer', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/dedicated_account', Mockery::on(fn($o) => $o['json']['customer'] === 'CUS_xxx')) + ->andReturn(Fixtures::dedicatedAccount()); + + $response = $this->service->create('CUS_xxx'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('NUBAN successfully created'); +}); + +it('lists dedicated accounts with pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/dedicated_account', Mockery::any()) + ->andReturn(Fixtures::dedicatedAccountList()); + + $response = $this->service->list(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Dedicated accounts retrieved'); +}); + +it('assigns a dedicated account with correct payload', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/dedicated_account/assign', Mockery::on( + fn($o) => $o['json']['email'] === 'test@example.com' + && $o['json']['preferred_bank'] === 'wema-bank' + && $o['json']['country'] === 'NG' + )) + ->andReturn(Fixtures::dedicatedAccount()); + + $response = $this->service->assign('test@example.com', 'Test', 'User', '+2348000000000', 'wema-bank', 'NG'); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches a dedicated account by id', function () { + $this->client->shouldReceive('send')->once()->with('GET', '/dedicated_account/123')->andReturn(Fixtures::dedicatedAccount()); + + $response = $this->service->fetch('123'); + expect($response->getStatus())->toBeTrue(); +}); + +it('deactivates a dedicated account using DELETE', function () { + $this->client->shouldReceive('send')->once()->with('DELETE', '/dedicated_account/123')->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->deactivate('123'); + expect($response->getStatus())->toBeTrue(); +}); + +it('requeries a dedicated account', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/dedicated_account/requery', Mockery::on( + fn($o) => $o['query']['account_number'] === '0123456789' && $o['query']['provider_slug'] === 'wema-bank' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->requery('0123456789', 'wema-bank'); + expect($response->getStatus())->toBeTrue(); +}); + +it('adds a split to a dedicated account', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/dedicated_account/split', Mockery::on(fn($o) => $o['json']['customer'] === 'CUS_xxx')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->addSplit('CUS_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('removes a split from a dedicated account', function () { + $this->client->shouldReceive('send') + ->once() + ->with('DELETE', '/dedicated_account/split', Mockery::on(fn($o) => $o['json']['account_number'] === '0123456789')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->removeSplit('0123456789'); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists available bank providers', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/dedicated_account/available_providers') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->availableProviders(); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/DirectDebitTest.php b/tests/Unit/Services/DirectDebitTest.php new file mode 100644 index 0000000..e296581 --- /dev/null +++ b/tests/Unit/Services/DirectDebitTest.php @@ -0,0 +1,45 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new DirectDebit(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('triggers a direct debit activation charge', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/directdebit/activation-charge', Mockery::on(fn($o) => $o['json']['customer'] === 'CUS_xxx')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->triggerActivationCharge(['customer' => 'CUS_xxx', 'amount' => 5000]); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists mandate authorizations with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/directdebit/mandate-authorizations', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->listMandateAuthorizations(); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists mandate authorizations with custom pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/directdebit/mandate-authorizations', Mockery::on( + fn($o) => $o['query']['perPage'] === 10 && $o['query']['page'] === 2 + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->listMandateAuthorizations(10, 2); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/DisputesTest.php b/tests/Unit/Services/DisputesTest.php new file mode 100644 index 0000000..fba0f8c --- /dev/null +++ b/tests/Unit/Services/DisputesTest.php @@ -0,0 +1,101 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Disputes(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('lists disputes with date range', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/dispute', Mockery::on( + fn($o) => $o['query']['from'] === '2024-01-01' && $o['query']['to'] === '2024-12-31' + )) + ->andReturn(Fixtures::disputeList()); + + $response = $this->service->listDisputes('2024-01-01', '2024-12-31'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Disputes retrieved'); +}); + +it('fetches a dispute by id', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/dispute/DIS_xxx') + ->andReturn(['status' => true, 'message' => 'Dispute retrieved', 'data' => Fixtures::disputeList()['data'][0]]); + + $response = $this->service->fetchDispute('DIS_xxx'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Dispute retrieved'); +}); + +it('lists disputes for a transaction', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/dispute/transaction/TXN_xxx') + ->andReturn(['status' => true, 'message' => 'Dispute retrieved', 'data' => Fixtures::disputeList()['data'][0]]); + + $response = $this->service->listTransactionDisputes('TXN_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a dispute', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/dispute/DIS_xxx', Mockery::on(fn($o) => isset($o['json']))) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->updateDispute('DIS_xxx', ['refund_amount' => 500]); + expect($response->getStatus())->toBeTrue(); +}); + +it('adds evidence to a dispute', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/dispute/DIS_xxx/evidence', Mockery::on( + fn($o) => $o['json']['customer_email'] === 'user@example.com' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->addEvidence('DIS_xxx', 'user@example.com', 'John Doe', '+2348000000000', 'Software subscription'); + expect($response->getStatus())->toBeTrue(); +}); + +it('gets the upload url for evidence', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/dispute/DIS_xxx/upload_url', Mockery::on(fn($o) => $o['query']['filename'] === 'receipt.pdf')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->getUploadUrl('DIS_xxx', 'receipt.pdf'); + expect($response->getStatus())->toBeTrue(); +}); + +it('resolves a dispute', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/dispute/DIS_xxx/resolve', Mockery::on( + fn($o) => $o['json']['resolution'] === 'merchant-accepted' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->resolveDispute('DIS_xxx', 'merchant-accepted', 'Resolved in favour of merchant', 0, 'receipt.pdf'); + expect($response->getStatus())->toBeTrue(); +}); + +it('exports disputes with date range', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/dispute/export', Mockery::on( + fn($o) => $o['query']['from'] === '2024-01-01' + )) + ->andReturn(Fixtures::disputeList()); + + $response = $this->service->exportDisputes('2024-01-01', '2024-12-31'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/IntegrationTest.php b/tests/Unit/Services/IntegrationTest.php new file mode 100644 index 0000000..c322f4f --- /dev/null +++ b/tests/Unit/Services/IntegrationTest.php @@ -0,0 +1,32 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Integration(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('fetches the payment session timeout', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/integration/payment_session_timeout') + ->andReturn(Fixtures::timeout()); + + $response = $this->service->fetchTimeout(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Payment session timeout retrieved'); +}); + +it('updates the payment session timeout', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/integration/payment_session_timeout', Mockery::on(fn($o) => $o['json']['timeout'] === 60)) + ->andReturn(Fixtures::timeout()); + + $response = $this->service->updateTimeout(60); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/MiscellaneousTest.php b/tests/Unit/Services/MiscellaneousTest.php new file mode 100644 index 0000000..fba3164 --- /dev/null +++ b/tests/Unit/Services/MiscellaneousTest.php @@ -0,0 +1,44 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Miscellaneous(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('lists supported countries', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/country') + ->andReturn(Fixtures::countryList()); + + $response = $this->service->listCountries(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Countries retrieved'); +}); + +it('lists states for a country', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/address_verification/states', Mockery::on(fn($o) => $o['query']['country'] === 'US')) + ->andReturn(['status' => true, 'message' => 'States retrieved', 'data' => [['name' => 'Lagos', 'slug' => 'lagos', 'abbreviation' => 'LA']]]); + + $response = $this->service->listStates('US'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('States retrieved'); +}); + +it('lists banks for a country', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/bank', Mockery::on(fn($o) => $o['query']['country'] === 'nigeria')) + ->andReturn(Fixtures::bankList()); + + $response = $this->service->listBanks('nigeria'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Banks retrieved'); +}); diff --git a/tests/Unit/Services/OrderTest.php b/tests/Unit/Services/OrderTest.php new file mode 100644 index 0000000..5cece7b --- /dev/null +++ b/tests/Unit/Services/OrderTest.php @@ -0,0 +1,77 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Order(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates an order with the given data', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/order', Mockery::on(fn($o) => $o['json']['amount'] === 5000)) + ->andReturn(['status' => true, 'message' => 'Order created', 'data' => ['id' => 'ord_xxx', 'amount' => 5000]]); + + $response = $this->service->create(['amount' => 5000, 'currency' => 'NGN']); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Order created'); +}); + +it('lists orders with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/order', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(['status' => true, 'message' => 'Orders retrieved', 'data' => []]); + + $response = $this->service->list(); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists orders with custom pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/order', Mockery::on( + fn($o) => $o['query']['perPage'] === 20 && $o['query']['page'] === 3 + )) + ->andReturn(['status' => true, 'message' => 'Orders retrieved', 'data' => []]); + + $response = $this->service->list(20, 3); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches an order by id', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/order/ord_xxx') + ->andReturn(['status' => true, 'message' => 'Order retrieved', 'data' => ['id' => 'ord_xxx', 'amount' => 5000]]); + + $response = $this->service->fetch('ord_xxx'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Order retrieved'); +}); + +it('fetches orders for a product', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/order/product/123') + ->andReturn(['status' => true, 'message' => 'Orders retrieved', 'data' => []]); + + $response = $this->service->fetchByProduct('123'); + expect($response->getStatus())->toBeTrue(); +}); + +it('validates a pay-for-me order', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/order/ORD_xxx/validate') + ->andReturn(['status' => true, 'message' => 'Order validated', 'data' => []]); + + $response = $this->service->validate('ORD_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/PaymentPagesTest.php b/tests/Unit/Services/PaymentPagesTest.php new file mode 100644 index 0000000..6a93f26 --- /dev/null +++ b/tests/Unit/Services/PaymentPagesTest.php @@ -0,0 +1,77 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new PaymentPages(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a payment page', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/page', Mockery::on(fn($o) => $o['json']['name'] === 'My Page')) + ->andReturn(Fixtures::paymentPage()); + + $response = $this->service->createPaymentPage('My Page'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Page created'); +}); + +it('lists payment pages with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/page', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(Fixtures::paymentPageList()); + + $response = $this->service->listPaymentPages(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Pages retrieved'); +}); + +it('fetches a payment page by identifier', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/page/pg_xxx') + ->andReturn(Fixtures::paymentPage()); + + $response = $this->service->fetchPaymentPage('pg_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a payment page with PUT', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/page/pg_xxx', Mockery::on(fn($o) => $o['json']['name'] === 'Updated')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->updatePaymentPage('pg_xxx', ['name' => 'Updated']); + expect($response->getStatus())->toBeTrue(); +}); + +it('checks slug availability', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/page/check_slug_availability/my-slug') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->checkSlugAvailability('my-slug'); + expect($response->getStatus())->toBeTrue(); +}); + +it('adds products to a payment page', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/page/pg_xxx/product', Mockery::on( + fn($o) => $o['json']['products'] === [1, 2, 3] + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->addProduct('pg_xxx', [1, 2, 3]); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/PaymentRequestsTest.php b/tests/Unit/Services/PaymentRequestsTest.php new file mode 100644 index 0000000..be40965 --- /dev/null +++ b/tests/Unit/Services/PaymentRequestsTest.php @@ -0,0 +1,104 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new PaymentRequests(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a payment request', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/paymentrequest', Mockery::on( + fn($o) => $o['json']['customer'] === 'CUS_xxx' && $o['json']['amount'] === 5000 + )) + ->andReturn(Fixtures::paymentRequest()); + + $response = $this->service->createPaymentRequest('CUS_xxx', 5000); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Payment request created'); +}); + +it('lists payment requests', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/paymentrequest', Mockery::any()) + ->andReturn(['status' => true, 'message' => 'Payment requests retrieved', 'data' => [Fixtures::paymentRequest()['data']]]); + + $response = $this->service->listPaymentRequests('CUS_xxx', 'pending', 'NGN', false); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches a payment request by identifier', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/paymentrequest/PRQ_xxx') + ->andReturn(Fixtures::paymentRequest()); + + $response = $this->service->fetchPaymentRequest('PRQ_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('verifies a payment request by code', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/paymentrequest/verify/PRQ_xxx') + ->andReturn(Fixtures::paymentRequest()); + + $response = $this->service->verifyPaymentRequest('PRQ_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('sends a notification for a payment request', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/paymentrequest/notify/PRQ_xxx') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->sendNotification('PRQ_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('gets payment request totals', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/paymentrequest/totals') + ->andReturn(['status' => true, 'message' => 'Payment request totals', 'data' => ['pending' => 0, 'successful' => 0, 'total' => 0]]); + + $response = $this->service->paymentRequestTotal(); + expect($response->getStatus())->toBeTrue(); +}); + +it('finalizes a draft payment request', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/paymentrequest/finalize/PRQ_xxx', Mockery::on(fn($o) => $o['json']['notify'] === true)) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->finalizePayment('PRQ_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a payment request', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/paymentrequest/PRQ_xxx', Mockery::on(fn($o) => $o['json']['amount'] === 10000)) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->updatePaymentRequest('PRQ_xxx', ['amount' => 10000]); + expect($response->getStatus())->toBeTrue(); +}); + +it('archives a payment request', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/paymentrequest/archive/PRQ_xxx') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->archivePaymentRequest('PRQ_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/PlansTest.php b/tests/Unit/Services/PlansTest.php new file mode 100644 index 0000000..4d0679c --- /dev/null +++ b/tests/Unit/Services/PlansTest.php @@ -0,0 +1,59 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Plans(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a plan with required fields', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/plan', Mockery::on( + fn($o) => $o['json']['name'] === 'Monthly' + && $o['json']['amount'] === 5000 + && $o['json']['interval'] === 'monthly' + )) + ->andReturn(Fixtures::plan()); + + $response = $this->service->create('Monthly', 5000, 'monthly'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Plan created'); +}); + +it('lists plans with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/plan', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(Fixtures::planList()); + + $response = $this->service->list(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Plans retrieved'); +}); + +it('fetches a plan by identifier', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/plan/PLN_xxx') + ->andReturn(Fixtures::plan()); + + $response = $this->service->fetch('PLN_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a plan with PUT', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/plan/PLN_xxx', Mockery::on(fn($o) => $o['json']['name'] === 'Updated')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->update('PLN_xxx', ['name' => 'Updated']); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/ProductsTest.php b/tests/Unit/Services/ProductsTest.php new file mode 100644 index 0000000..f10386b --- /dev/null +++ b/tests/Unit/Services/ProductsTest.php @@ -0,0 +1,69 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Products(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a product', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/product', Mockery::on( + fn($o) => $o['json']['name'] === 'T-shirt' + && $o['json']['price'] === 2500 + && $o['json']['currency'] === 'NGN' + )) + ->andReturn(Fixtures::product()); + + $response = $this->service->createProduct('T-shirt', 'A nice shirt', 2500, 'NGN'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Product successfully created'); +}); + +it('fetches a product by id', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/product/PROD_xxx') + ->andReturn(Fixtures::product()); + + $response = $this->service->fetchProduct('PROD_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a product with PUT', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/product/PROD_xxx', Mockery::on(fn($o) => $o['json']['name'] === 'Updated Shirt')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->updateProduct('PROD_xxx', ['name' => 'Updated Shirt']); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists products with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/product', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(Fixtures::productList()); + + $response = $this->service->listProducts(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Products retrieved'); +}); + +it('deletes a product by id', function () { + $this->client->shouldReceive('send') + ->once() + ->with('DELETE', '/product/PROD_xxx') + ->andReturn(['status' => true, 'message' => 'Product successfully deleted', 'data' => []]); + + $response = $this->service->deleteProduct('PROD_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/RecipientsTest.php b/tests/Unit/Services/RecipientsTest.php new file mode 100644 index 0000000..9adf12b --- /dev/null +++ b/tests/Unit/Services/RecipientsTest.php @@ -0,0 +1,85 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Recipients(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a recipient with required fields', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transferrecipient', Mockery::on( + fn($o) => $o['json']['type'] === 'nuban' + && $o['json']['name'] === 'John Doe' + && $o['json']['account_number'] === '0123456789' + && $o['json']['bank_code'] === '058' + )) + ->andReturn(['status' => true, 'message' => 'Transfer recipient created successfully', 'data' => []]); + + $response = $this->service->createRecipient('nuban', 'John Doe', '0123456789', '058'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Transfer recipient created successfully'); +}); + +it('creates bulk recipients', function () { + $batch = [ + ['type' => 'nuban', 'name' => 'A', 'account_number' => '001', 'bank_code' => '058'], + ['type' => 'nuban', 'name' => 'B', 'account_number' => '002', 'bank_code' => '058'], + ]; + + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transferrecipient/bulk', Mockery::on(fn($o) => count($o['json']['batch']) === 2)) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->createBulkRecipients($batch); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists recipients with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/transferrecipient', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(['status' => true, 'message' => 'Transfer recipients retrieved', 'data' => []]); + + $response = $this->service->listRecipients(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Transfer recipients retrieved'); +}); + +it('fetches a recipient by identifier', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/transferrecipient/RCP_xxx') + ->andReturn(['status' => true, 'message' => 'Transfer recipient retrieved', 'data' => []]); + + $response = $this->service->fetchRecipient('RCP_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a recipient with PUT', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/transferrecipient/RCP_xxx', Mockery::on(fn($o) => $o['json']['name'] === 'Updated')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->updateRecipient('RCP_xxx', ['name' => 'Updated']); + expect($response->getStatus())->toBeTrue(); +}); + +it('deletes a recipient using DELETE', function () { + $this->client->shouldReceive('send') + ->once() + ->with('DELETE', '/transferrecipient/RCP_xxx') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->deleteRecipient('RCP_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/RefundsTest.php b/tests/Unit/Services/RefundsTest.php new file mode 100644 index 0000000..d6800b4 --- /dev/null +++ b/tests/Unit/Services/RefundsTest.php @@ -0,0 +1,58 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Refunds(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a refund for a transaction', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/refund', Mockery::on(fn($o) => $o['json']['transaction'] === 'TXN_xxx')) + ->andReturn(Fixtures::refund()); + + $response = $this->service->createRefund('TXN_xxx'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Refund created'); +}); + +it('retries a failed refund', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/refund/retry_with_customer_details/REF_xxx', Mockery::on( + fn($o) => $o['json']['refund_account_details']['currency'] === 'NGN' + && $o['json']['refund_account_details']['account_number'] === '0123456789' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->retryRefund('REF_xxx', 'NGN', 'bank_123', '0123456789'); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists refunds for a transaction', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/refund', Mockery::on( + fn($o) => $o['query']['transaction'] === 'TXN_xxx' && $o['query']['currency'] === 'NGN' + )) + ->andReturn(['status' => true, 'message' => 'Refunds retrieved', 'data' => [Fixtures::refund()['data']]]); + + $response = $this->service->listRefunds('TXN_xxx', 'NGN'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Refunds retrieved'); +}); + +it('fetches a refund by id', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/refund/REF_xxx') + ->andReturn(Fixtures::refund()); + + $response = $this->service->fetchRefund('REF_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/SettlementsTest.php b/tests/Unit/Services/SettlementsTest.php new file mode 100644 index 0000000..e511200 --- /dev/null +++ b/tests/Unit/Services/SettlementsTest.php @@ -0,0 +1,37 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Settlements(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('lists settlements with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/settlement', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(Fixtures::settlementList()); + + $response = $this->service->listSettlements(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Settlements retrieved'); +}); + +it('lists settlement transactions for a settlement id', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/settlement/STL_xxx/transactions', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(['status' => true, 'message' => 'Settlement transactions retrieved', 'data' => []]); + + $response = $this->service->listSettlementTransactions('STL_xxx'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Settlement transactions retrieved'); +}); diff --git a/tests/Unit/Services/SplitsTest.php b/tests/Unit/Services/SplitsTest.php new file mode 100644 index 0000000..258f150 --- /dev/null +++ b/tests/Unit/Services/SplitsTest.php @@ -0,0 +1,78 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Splits(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a split with required fields', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/split', Mockery::on( + fn($o) => $o['json']['name'] === 'Main Split' + && $o['json']['type'] === 'percentage' + && $o['json']['currency'] === 'NGN' + && $o['json']['bearer_type'] === 'account' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->createSplit('Main Split', 'percentage', 'NGN', [], 'account', 'ACCT_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists splits by name', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/split', Mockery::on(fn($o) => $o['query']['name'] === 'Main Split')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->listSplit('Main Split'); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches a split by id', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/split/SPL_xxx') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->fetchSplit('SPL_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a split with PUT', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/split/SPL_xxx', Mockery::on(fn($o) => $o['json']['name'] === 'Updated')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->updateSplit('SPL_xxx', ['name' => 'Updated']); + expect($response->getStatus())->toBeTrue(); +}); + +it('adds a subaccount to a split', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/split/SPL_xxx/subaccount/add', Mockery::on( + fn($o) => $o['json']['subaccount'] === 'ACCT_xxx' && $o['json']['share'] === 20 + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->addSubaccountSplit('SPL_xxx', 'ACCT_xxx', 20); + expect($response->getStatus())->toBeTrue(); +}); + +it('removes a subaccount from a split', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/split/SPL_xxx/subaccount/remove', Mockery::on(fn($o) => $o['json']['subaccount'] === 'ACCT_xxx')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->removeSubaccountSplit('SPL_xxx', 'ACCT_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/StorefrontTest.php b/tests/Unit/Services/StorefrontTest.php new file mode 100644 index 0000000..c1c457c --- /dev/null +++ b/tests/Unit/Services/StorefrontTest.php @@ -0,0 +1,136 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Storefront(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a storefront', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/storefront', Mockery::on(fn($o) => $o['json']['name'] === 'My Store')) + ->andReturn(['status' => true, 'message' => 'Storefront created', 'data' => ['id' => 1]]); + + $response = $this->service->create(['name' => 'My Store', 'slug' => 'my-store']); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists storefronts with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/storefront', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->list(); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists storefronts with custom pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/storefront', Mockery::on( + fn($o) => $o['query']['perPage'] === 10 && $o['query']['page'] === 2 + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->list(10, 2); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches a storefront by id', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/storefront/1') + ->andReturn(['status' => true, 'message' => 'Storefront retrieved', 'data' => ['id' => 1]]); + + $response = $this->service->fetch('1'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Storefront retrieved'); +}); + +it('updates a storefront', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/storefront/1', Mockery::on(fn($o) => $o['json']['name'] === 'New Name')) + ->andReturn(['status' => true, 'message' => 'Storefront updated', 'data' => []]); + + $response = $this->service->update('1', ['name' => 'New Name']); + expect($response->getStatus())->toBeTrue(); +}); + +it('deletes a storefront', function () { + $this->client->shouldReceive('send') + ->once() + ->with('DELETE', '/storefront/1') + ->andReturn(['status' => true, 'message' => 'Storefront deleted', 'data' => []]); + + $response = $this->service->delete('1'); + expect($response->getStatus())->toBeTrue(); +}); + +it('verifies a storefront slug', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/storefront/verify/my-store') + ->andReturn(['status' => true, 'message' => 'Slug available', 'data' => []]); + + $response = $this->service->verifySlug('my-store'); + expect($response->getStatus())->toBeTrue(); +}); + +it('duplicates a storefront', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/storefront/1/duplicate') + ->andReturn(['status' => true, 'message' => 'Storefront duplicated', 'data' => []]); + + $response = $this->service->duplicate('1'); + expect($response->getStatus())->toBeTrue(); +}); + +it('publishes a storefront', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/storefront/1/publish') + ->andReturn(['status' => true, 'message' => 'Storefront published', 'data' => []]); + + $response = $this->service->publish('1'); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches orders for a storefront', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/storefront/1/order') + ->andReturn(['status' => true, 'message' => 'Orders retrieved', 'data' => []]); + + $response = $this->service->fetchOrders('1'); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists products for a storefront', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/storefront/1/product') + ->andReturn(['status' => true, 'message' => 'Products retrieved', 'data' => []]); + + $response = $this->service->listProducts('1'); + expect($response->getStatus())->toBeTrue(); +}); + +it('adds products to a storefront', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/storefront/1/product', Mockery::on(fn($o) => $o['json']['products'] === [10, 20])) + ->andReturn(['status' => true, 'message' => 'Products added', 'data' => []]); + + $response = $this->service->addProducts('1', ['products' => [10, 20]]); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/SubaccountsTest.php b/tests/Unit/Services/SubaccountsTest.php new file mode 100644 index 0000000..3c784dd --- /dev/null +++ b/tests/Unit/Services/SubaccountsTest.php @@ -0,0 +1,60 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Subaccounts(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a subaccount with required fields', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/subaccount', Mockery::on( + fn($o) => $o['json']['business_name'] === 'Acme Inc' + && $o['json']['settlement_bank'] === '058' + && $o['json']['account_number'] === '0123456789' + && $o['json']['percentage_charge'] === 2.5 + )) + ->andReturn(Fixtures::subaccount()); + + $response = $this->service->createSubaccount('Acme Inc', '058', '0123456789', 2.5); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Subaccount created'); +}); + +it('lists subaccounts with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/subaccount', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(Fixtures::subaccountList()); + + $response = $this->service->listSubaccounts(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Subaccounts retrieved'); +}); + +it('fetches a subaccount by identifier', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/subaccount/ACCT_xxx') + ->andReturn(Fixtures::subaccount()); + + $response = $this->service->fetchSubaccount('ACCT_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a subaccount with PUT', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/subaccount/ACCT_xxx', Mockery::on(fn($o) => $o['json']['business_name'] === 'Updated')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->updateSubaccount('ACCT_xxx', ['business_name' => 'Updated']); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/SubscriptionsTest.php b/tests/Unit/Services/SubscriptionsTest.php new file mode 100644 index 0000000..81926f7 --- /dev/null +++ b/tests/Unit/Services/SubscriptionsTest.php @@ -0,0 +1,91 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Subscriptions(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a subscription', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/subscription', Mockery::on( + fn($o) => $o['json']['customer'] === 'CUS_xxx' && $o['json']['plan'] === 'PLN_xxx' + )) + ->andReturn(Fixtures::subscription()); + + $response = $this->service->create('CUS_xxx', 'PLN_xxx'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Subscription successfully created'); +}); + +it('lists subscriptions with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/subscription', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(Fixtures::subscriptionList()); + + $response = $this->service->list(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Subscriptions retrieved'); +}); + +it('fetches a subscription by identifier', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/subscription/SUB_xxx') + ->andReturn(Fixtures::subscription()); + + $response = $this->service->fetch('SUB_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('enables a subscription', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/subscription/enable', Mockery::on( + fn($o) => $o['json']['code'] === 'SUB_xxx' && $o['json']['token'] === 'tok_xxx' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->toggle('SUB_xxx', 'tok_xxx', true); + expect($response->getStatus())->toBeTrue(); +}); + +it('disables a subscription', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/subscription/disable', Mockery::on( + fn($o) => $o['json']['code'] === 'SUB_xxx' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->toggle('SUB_xxx', 'tok_xxx', false); + expect($response->getStatus())->toBeTrue(); +}); + +it('generates an update subscription link', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/subscription/SUB_xxx/manage/link') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->generateUpdateSubscriptionLink('SUB_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('sends an update subscription link email', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/subscription/SUB_xxx/manage/email') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->sendUpdateSubscriptionLink('SUB_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/TerminalTest.php b/tests/Unit/Services/TerminalTest.php new file mode 100644 index 0000000..d15d57d --- /dev/null +++ b/tests/Unit/Services/TerminalTest.php @@ -0,0 +1,92 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Terminal(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('sends an event to a terminal', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/terminal/TRM_xxx/event', Mockery::on( + fn($o) => $o['json']['type'] === 'invoice' + && $o['json']['action'] === 'process' + && $o['json']['data'] === ['id' => 1] + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->sendEvent('TRM_xxx', 'invoice', 'process', ['id' => 1]); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches event status', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/terminal/TRM_xxx/event/EVT_xxx') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->fetchEventStatus('TRM_xxx', 'EVT_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches terminal presence/status', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/terminal/TRM_xxx/presence') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->fetchTerminalStatus('TRM_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('lists terminals with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/terminal', Mockery::on(fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1)) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->list(); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches a terminal by id', function () { + $this->client->shouldReceive('send')->once()->with('GET', '/terminal/TRM_xxx')->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->fetch('TRM_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a terminal', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/terminal/TRM_xxx', Mockery::on(fn($o) => $o['json']['name'] === 'My Terminal')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->update('TRM_xxx', ['name' => 'My Terminal']); + expect($response->getStatus())->toBeTrue(); +}); + +it('commissions a terminal by serial number', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/terminal/commission_device', Mockery::on(fn($o) => $o['json']['serial_number'] === 'SN123')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->commission('SN123'); + expect($response->getStatus())->toBeTrue(); +}); + +it('decommissions a terminal by serial number', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/terminal/decommission_device', Mockery::on(fn($o) => $o['json']['serial_number'] === 'SN123')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->decommission('SN123'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/TransactionsTest.php b/tests/Unit/Services/TransactionsTest.php new file mode 100644 index 0000000..785b531 --- /dev/null +++ b/tests/Unit/Services/TransactionsTest.php @@ -0,0 +1,117 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Transactions(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('initializes a transaction', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transaction/initialize', Mockery::on( + fn($o) => $o['json']['amount'] === 10000 && $o['json']['email'] === 'test@example.com' + )) + ->andReturn(Fixtures::transactionInitialize()); + + $response = $this->service->initialize(10000, 'test@example.com'); + expect($response)->toBeInstanceOf(Response::class); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Authorization URL created'); +}); + +it('verifies a transaction by reference', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/transaction/verify/ref_123') + ->andReturn(Fixtures::transaction()); + + $response = $this->service->verify('ref_123'); + expect($response)->toBeInstanceOf(Response::class); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Verification successful'); +}); + +it('lists transactions with pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/transaction', Mockery::on(fn($o) => $o['query']['perPage'] === 25 && $o['query']['page'] === 2)) + ->andReturn(Fixtures::transactionList()); + + $response = $this->service->list(25, 2); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Transactions retrieved'); +}); + +it('fetches a transaction by id', function () { + $this->client->shouldReceive('send')->once()->with('GET', '/transaction/12345')->andReturn(Fixtures::transaction()); + + $response = $this->service->fetch('12345'); + expect($response->getStatus())->toBeTrue(); +}); + +it('charges an authorization with correct payload', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transaction/charge_authorization', Mockery::on( + fn($o) => $o['json']['authorization_code'] === 'AUTH_xxx' + && $o['json']['amount'] === 5000 + && $o['json']['email'] === 'customer@example.com' + )) + ->andReturn(Fixtures::transaction()); + + $response = $this->service->chargeAuthorization('AUTH_xxx', 5000, 'customer@example.com'); + expect($response->getStatus())->toBeTrue(); +}); + +it('uses the correct endpoint for partial debit', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transaction/partial_debit', Mockery::any()) + ->andReturn(Fixtures::transaction()); + + $response = $this->service->partialDebit('AUTH_xxx', 5000, Currency::NGN, 'customer@example.com'); + expect($response->getStatus())->toBeTrue(); +}); + +it('exports transactions', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/transaction/export', Mockery::any()) + ->andReturn(['status' => true, 'message' => 'Export successful', 'data' => ['path' => 'https://example.com/export.csv']]); + + $response = $this->service->export(); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Export successful'); +}); + +it('fetches transaction totals', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/transaction/totals', Mockery::any()) + ->andReturn([ + 'status' => true, + 'message' => 'Transaction totals', + 'data' => [ + 'total_volume' => 100000, + 'total_transactions' => 10, + 'pending_transfers' => 0, + ], + ]); + + $response = $this->service->transactionTotals(); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches transaction timeline', function () { + $this->client->shouldReceive('send')->once()->with('GET', '/transaction/timeline/12345')->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->viewTransactionTimeline('12345'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/TransfersTest.php b/tests/Unit/Services/TransfersTest.php new file mode 100644 index 0000000..dfcc04a --- /dev/null +++ b/tests/Unit/Services/TransfersTest.php @@ -0,0 +1,102 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Transfers(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('initiates a transfer with correct payload', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transfer', Mockery::on( + fn($o) => $o['json']['amount'] === 50000 + && $o['json']['recipient'] === 'RCP_xxx' + && $o['json']['reference'] === 'ref_abc123' + )) + ->andReturn(Fixtures::transfer()); + + $response = $this->service->initiateTransfer(50000, 'RCP_xxx', 'ref_abc123'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Transfer requires OTP to continue'); +}); + +it('finalizes a transfer with otp', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transfer/finalize_transfer', Mockery::on( + fn($o) => $o['json']['transfer_code'] === 'TRF_xxx' && $o['json']['otp'] === '123456' + )) + ->andReturn(Fixtures::transfer()); + + $response = $this->service->finalizeTransfer('TRF_xxx', '123456'); + expect($response->getStatus())->toBeTrue(); +}); + +it('uses the correct endpoint for bulk transfer', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transfer/bulk', Mockery::any()) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->initiateBulkTransfer(Currency::NGN, [ + ['amount' => 10000, 'recipient' => 'RCP_1'], + ['amount' => 20000, 'recipient' => 'RCP_2'], + ]); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches a transfer by identifier', function () { + $this->client->shouldReceive('send')->once()->with('GET', '/transfer/TRF_xxx')->andReturn(Fixtures::transfer()); + + $response = $this->service->fetchTransfer('TRF_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('verifies a transfer by reference', function () { + $this->client->shouldReceive('send')->once()->with('GET', '/transfer/verify/ref_123')->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->verifyTransfer('ref_123'); + expect($response->getStatus())->toBeTrue(); +}); + +it('exports transfers', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/transfer/export', Mockery::any()) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->exportTransfers(); + expect($response->getStatus())->toBeTrue(); +}); + +it('resends otp with correct payload', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/transfer/resend_otp', Mockery::on( + fn($o) => $o['json']['transfer_code'] === 'TRF_xxx' && $o['json']['reason'] === 'resend_otp' + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->resendOtp('TRF_xxx', 'resend_otp'); + expect($response->getStatus())->toBeTrue(); +}); + +it('disables otp requirement', function () { + $this->client->shouldReceive('send')->once()->with('POST', '/transfer/disable_otp')->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->disableOtp(); + expect($response->getStatus())->toBeTrue(); +}); + +it('enables otp requirement', function () { + $this->client->shouldReceive('send')->once()->with('POST', '/transfer/enable_otp')->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->enableOtp(); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/VerificationTest.php b/tests/Unit/Services/VerificationTest.php new file mode 100644 index 0000000..ada149b --- /dev/null +++ b/tests/Unit/Services/VerificationTest.php @@ -0,0 +1,45 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new Verification(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('resolves an account number', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/bank/resolve', Mockery::on( + fn($o) => $o['query']['account_number'] === '0123456789' && $o['query']['bank_code'] === '058' + )) + ->andReturn(Fixtures::resolvedAccount()); + + $response = $this->service->resolveAccount('0123456789', '058'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Account number resolved'); +}); + +it('resolves a card bin', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/decision/bin/539983') + ->andReturn(Fixtures::resolvedCardBin()); + + $response = $this->service->resolveCardBin('539983'); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Bin resolved'); +}); + +it('validates an account', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/bank/validate', Mockery::on(fn($o) => $o['json']['account_number'] === '0123456789')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->validateAccount(['account_number' => '0123456789', 'bank_code' => '058', 'account_name' => 'John Doe']); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/Services/VirtualTerminalTest.php b/tests/Unit/Services/VirtualTerminalTest.php new file mode 100644 index 0000000..9b4b635 --- /dev/null +++ b/tests/Unit/Services/VirtualTerminalTest.php @@ -0,0 +1,104 @@ +client = Mockery::mock(ClientInterface::class); + $this->service = new VirtualTerminal(client: $this->client); +}); + +afterEach(fn() => Mockery::close()); + +it('creates a virtual terminal', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/virtual_terminal', Mockery::on(fn($o) => $o['json']['name'] === 'Main Terminal')) + ->andReturn(['status' => true, 'message' => 'Virtual Terminal created', 'data' => ['id' => 'VT_xxx', 'name' => 'Main Terminal']]); + + $response = $this->service->create(['name' => 'Main Terminal']); + expect($response->getStatus())->toBeTrue(); + expect($response->getMessage())->toBe('Virtual Terminal created'); +}); + +it('lists virtual terminals with default pagination', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/virtual_terminal', Mockery::on( + fn($o) => $o['query']['perPage'] === 50 && $o['query']['page'] === 1 + )) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->list(); + expect($response->getStatus())->toBeTrue(); +}); + +it('fetches a virtual terminal by code', function () { + $this->client->shouldReceive('send') + ->once() + ->with('GET', '/virtual_terminal/VT_xxx') + ->andReturn(['status' => true, 'message' => 'Virtual Terminal created', 'data' => ['id' => 'VT_xxx', 'name' => 'Main Terminal']]); + + $response = $this->service->fetch('VT_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('updates a virtual terminal with PUT', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/virtual_terminal/VT_xxx', Mockery::on(fn($o) => $o['json']['name'] === 'Updated')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->update('VT_xxx', ['name' => 'Updated']); + expect($response->getStatus())->toBeTrue(); +}); + +it('deactivates a virtual terminal', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/virtual_terminal/VT_xxx/deactivate') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->deactivate('VT_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('assigns a destination to a virtual terminal', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/virtual_terminal/VT_xxx/destination/assign', Mockery::on(fn($o) => $o['json']['type'] === 'whatsapp')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->assignDestination('VT_xxx', ['type' => 'whatsapp']); + expect($response->getStatus())->toBeTrue(); +}); + +it('unassigns a destination from a virtual terminal', function () { + $this->client->shouldReceive('send') + ->once() + ->with('POST', '/virtual_terminal/VT_xxx/destination/unassign') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->unassignDestination('VT_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('adds a split code to a virtual terminal', function () { + $this->client->shouldReceive('send') + ->once() + ->with('PUT', '/virtual_terminal/VT_xxx/split_code', Mockery::on(fn($o) => $o['json']['split_code'] === 'SPL_xxx')) + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->addSplitCode('VT_xxx', 'SPL_xxx'); + expect($response->getStatus())->toBeTrue(); +}); + +it('removes a split code from a virtual terminal', function () { + $this->client->shouldReceive('send') + ->once() + ->with('DELETE', '/virtual_terminal/VT_xxx/split_code') + ->andReturn(['status' => true, 'message' => 'Request successful', 'data' => []]); + + $response = $this->service->removeSplitCode('VT_xxx'); + expect($response->getStatus())->toBeTrue(); +}); diff --git a/tests/Unit/SpecConformanceTest.php b/tests/Unit/SpecConformanceTest.php new file mode 100644 index 0000000..e163341 --- /dev/null +++ b/tests/Unit/SpecConformanceTest.php @@ -0,0 +1,114 @@ +client->send('VERB', '/path')` + * calls, normalizes their verb+path, and diffs the result against the canonical + * endpoint list derived from Paystack's official OpenAPI spec + * (tests/SpecEndpoints.php). + * + * Unlike the per-service mock tests — which assert that the SDK sends whatever + * the test author wrote — this test asserts the SDK matches the *spec*. It is the + * check that would have caught the bulkcharge pause/resume POST-vs-GET bug. + * + * When Paystack changes their API: regenerate tests/SpecEndpoints.php from the + * upstream spec, then make the services match. + */ + +/** + * Collapse path placeholders to a single `{}` token so spec placeholder names + * ({id}, {code}, {reference}, …) and PHP interpolations ({$id}, {$code}, …) + * compare equal. + */ +function normalizeEndpoint(string $verb, string $path): string +{ + $path = preg_replace('/\{[^}]*\}/', '{}', $path); + + return strtoupper($verb) . ' ' . $path; +} + +/** + * Endpoints produced via dynamic path construction (not a string literal in the + * send() call), which the static scanner cannot see. Keep this list tiny and + * justified — each entry is a deliberate escape hatch. + */ +function dynamicEndpoints(): array +{ + return [ + // Subscriptions::toggle() → sprintf('/subscription/%s', $active ? 'enable' : 'disable') + 'POST /subscription/enable', + 'POST /subscription/disable', + ]; +} + +/** + * Extract every literal verb+path the SDK can call, normalized. + * + * @return string[] + */ +function packageEndpoints(): array +{ + $found = dynamicEndpoints(); + + $dir = new RecursiveDirectoryIterator(__DIR__ . '/../../src/Services'); + foreach (new RecursiveIteratorIterator($dir) as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $code = file_get_contents($file->getPathname()); + + // ->send('VERB', '/literal/path' | "/literal/path") — ignores calls + // whose path is a variable (e.g. send('POST', $endpoint, …)). + preg_match_all( + '/->send\(\s*[\'"](GET|POST|PUT|DELETE|PATCH)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]/', + $code, + $matches, + PREG_SET_ORDER + ); + + foreach ($matches as [$_, $verb, $path]) { + $found[] = normalizeEndpoint($verb, $path); + } + } + + return array_values(array_unique($found)); +} + +/** + * The canonical spec list, normalized. + * + * @return string[] + */ +function specEndpoints(): array +{ + $raw = require __DIR__ . '/../SpecEndpoints.php'; + + return array_values(array_unique(array_map(function (string $entry) { + [$verb, $path] = explode(' ', $entry, 2); + + return normalizeEndpoint($verb, $path); + }, $raw))); +} + +it('implements no endpoint absent from the Paystack spec (no wrong verbs or typos)', function () { + $extra = array_diff(packageEndpoints(), specEndpoints()); + + sort($extra); + + expect($extra)->toBe([], 'These SDK calls do not match any spec endpoint: ' . implode(', ', $extra)); +}); + +it('covers every endpoint defined in the Paystack spec (no gaps)', function () { + $missing = array_diff(specEndpoints(), packageEndpoints()); + + sort($missing); + + expect($missing)->toBe([], 'These spec endpoints are not implemented: ' . implode(', ', $missing)); +}); + +it('parsed a sane number of endpoints from both sides', function () { + expect(count(specEndpoints()))->toBeGreaterThan(150) + ->and(count(packageEndpoints()))->toBeGreaterThan(150); +}); diff --git a/tests/Unit/WebhookTest.php b/tests/Unit/WebhookTest.php new file mode 100644 index 0000000..1b109dd --- /dev/null +++ b/tests/Unit/WebhookTest.php @@ -0,0 +1,39 @@ + hash_hmac('sha512', $payload, $secretKey); + +it('passes a valid signature', function () use ($secretKey, $sign) { + $payload = json_encode(['event' => 'charge.success', 'data' => []]); + + expect(Webhook::validateSignature($payload, $sign($payload), $secretKey)) + ->toBeInstanceOf(Webhook::class); +}); + +it('throws on invalid signature', function () use ($secretKey) { + Webhook::validateSignature('payload', 'bad-signature', $secretKey); +})->throws(PaystackException::class, 'Invalid signature'); + +it('throws when secret key is empty', function () { + Webhook::validateSignature('payload', 'sig', ''); +})->throws(PaystackException::class, 'No secret key provided'); + +it('passes a whitelisted IP', function () { + expect(Webhook::isIpWhitelisted('52.31.139.75')) + ->toBeInstanceOf(Webhook::class); +}); + +it('throws for a non-whitelisted IP', function () { + Webhook::isIpWhitelisted('1.2.3.4'); +})->throws(PaystackException::class, 'IP not whitelisted'); + +it('accepts all three Paystack whitelisted IPs', function () { + $ips = ['52.31.139.75', '52.49.173.169', '52.214.14.220']; + + foreach ($ips as $ip) { + expect(Webhook::isIpWhitelisted($ip))->toBeInstanceOf(Webhook::class); + } +});