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);
+ }
+});