diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index bf44c70..df6d745 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -1,13 +1,16 @@ -name: PHPUnit Tests +name: Tests on: + push: + branches: + - main pull_request: branches: - main jobs: run_tests: - name: Run PHPUnit Tests + name: Run Tests & Static Analysis runs-on: ubuntu-latest steps: @@ -23,5 +26,8 @@ jobs: - name: Install dependencies run: composer install --no-progress + - name: Run PHPStan + run: composer phpstan + - name: Run PHPUnit run: ./vendor/bin/phpunit --configuration phpunit.xml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9ffb4d5..eb9e971 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ vendor .fleet .idea .vscode -index.php \ No newline at end of file +index.php +phpstan-baseline.neon +MtnPaymentMethod.php +disbursement.yaml +CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md index b1d9be1..419b56b 100644 --- a/README.md +++ b/README.md @@ -3,186 +3,300 @@ [![Static Badge](https://img.shields.io/badge/Stable-v1.0.1-blue)](https://packagist.org/packages/lepresk/momo-api) ![GitHub](https://img.shields.io/github/license/lepresk/momo-api) +A powerful and professional PHP wrapper for integrating MTN Mobile Money API. Supports **Collection** (receive payments) and **Disbursement** (send money) operations. +## Features -La librairie **lepresk/momo-api** est une surcouche au-dessus de l'API officielle de Momo (Mobile Money). Elle facilite -l'interaction avec la plateforme Momo et fournit des fonctionnalités supplémentaires pour simplifier l'intégration et la -gestion des transactions financières. +| Product | Supported Operations | +|---------|---------------------| +| **Collection** | Request payments from customers, Check payment status, Get account balance | +| **Disbursement** | Transfer money, Deposit funds, Process refunds, Get account balance | +| **Sandbox** | Create API users, Generate API keys, Test environment support | -## Fonctionnalités +## Requirements -La librairie **lepresk/momo-api** vous permet de : - -| Produit | Support | -|--------------|-------------------------------------------------------------------------------------------------------------------------------| -| Sandbox | - Créer un api user
- Créer un api key
- Récupérer les informations du compte | -| Collection | - Récupérer le solde du compte
- Faire un requestToPay
- Vérifier le statut d'une transaction
- Gérer le callback | -| Disbursement | - *En cours d'implémentation* | - -## Configuration requise - -- PHP 7.4 ou supérieur. -- Avoir un compte sur [Momo Developper](https://momodeveloper.mtn.com/) et récupérer la `subscriptionKey` ou avoir les clés d'API fournit par MTN si vous êtes en production. - -> 📢 En production la `subscriptionKey`, le `apiUser` et le `apiKey` vous sont directement fourni par MTN +- PHP 7.4 or higher +- MTN MoMo Developer Account ([Sign up](https://momodeveloper.mtn.com/)) +- Subscription Key (sandbox or production) ## Installation -Pour installer la librairie **lepresk/momo-api**, vous pouvez utiliser [Composer](https://getcomposer.org/) : - ```bash composer require lepresk/momo-api ``` -## Utilisation +## Quick Start -Voici un exemple simple d'utilisation de la librairie : +### Collection API (Receive Payments) ```php 'sandbox', // or 'mtncongo', 'mtnuganda', etc. + 'subscription_key' => 'YOUR_SUBSCRIPTION_KEY', + 'api_user' => 'YOUR_API_USER', + 'api_key' => 'YOUR_API_KEY', + 'callback_url' => 'https://yourdomain.com/callback' +]); -require 'vendor/autoload.php'; +// Quick payment - 3 parameters +$paymentId = $collection->quickPay('1000', '242068511358', 'ORDER-123'); -// Récupérer la subscriptionKey dans son profile ou utiliser celui fournit par MTN si vous êtes en production -$subscriptionKey = 'SUBSCRIPTION KEY HERE'; +// Check payment status +$transaction = $collection->getPaymentStatus($paymentId); -// Récupérer le client Momo -$momo = MomoApi::create(MomoApi::ENVIRONMENT_SANDBOX); +if ($transaction->isSuccessful()) { + echo "Payment of {$transaction->getAmount()} received!"; +} ``` -> 📢 Assurez-vous de remplacer "SUBSCRIPTION KEY HERE" par votre clé d'abonnement réelle. -Les environnements possibles - -| Constante | Valeur | Default | -|--------------------------------------|:----------------:|:-------:| -| `MomoApi::ENVIRONMENT_MTN_CONGO` | mtncongo | | -| `MomoApi::ENVIRONMENT_MTN_UGANDA` | mtnuganda | | -| `MomoApi::ENVIRONMENT_MTN_GHANA` | mtnghana | | -| `MomoApi::ENVIRONMENT_IVORY_COAST` | mtnivorycoast | | -| `MomoApi::ENVIRONMENT_ZAMBIA` | mtnzambia | | -| `MomoApi::ENVIRONMENT_CAMEROON` | mtncameroon | | -| `MomoApi::ENVIRONMENT_BENIN` | mtnbenin | | -| `MomoApi::ENVIRONMENT_SWAZILAND` | mtnswaziland | | -| `MomoApi::ENVIRONMENT_GUINEACONAKRY` | mtnguineaconakry | | -| `MomoApi::ENVIRONMENT_SOUTHAFRICA` | mtnsouthafrica | | -| `MomoApi::ENVIRONMENT_LIBERIA` | mtnliberia | | -| `MomoApi::ENVIRONMENT_SANDBOX` | sandbox | **OUI** | - -### Intéragir avec la sandbox - -#### Créer un api user +### Disbursement API (Send Money) ```php -// Créer une api user -$uuid = Utilities::guidv4(); // Ou tout autre guuidv4 valide -$callbackHost = 'https://my-domain.com/callback'; - -$apiUser = $momo->sandbox($subscriptionKey)->createApiUser($uuid, $callbackHost); -echo "Api user created: $apiUser\n"; -``` - -#### Récupérer les informations d'un utilisateur - -```php -$data = $momo->sandbox($subscriptionKey)->getApiUser($apiUser); -print_r($data); -// [ -// 'providerCallbackHost' => 'https://my-domain.com/callback', -// 'targetEnvironment' => 'sandbox', -// ] + 'sandbox', + 'subscription_key' => 'YOUR_SUBSCRIPTION_KEY', + 'api_user' => 'YOUR_API_USER', + 'api_key' => 'YOUR_API_KEY', + 'callback_url' => 'https://yourdomain.com/callback' +]); + +// Transfer money to a beneficiary +$transfer = TransferRequest::make('5000', '242068511358', 'SALARY-001'); +$transferId = $disbursement->transfer($transfer); + +// Check transfer status +$result = $disbursement->getTransferStatus($transferId); ``` -#### Créer une api key +## Sandbox Setup ```php -$apiKey = $momo->sandbox($subscriptionKey)->createApiKey($apiUser); -echo "Api token created: $apiKey\n"; -``` +sandbox($subscriptionKey)->createApiUser($uuid, $callbackHost); -```php -// Créer un object Config -$config = new \Lepresk\MomoApi\Config::collection($subscriptionKey, $apiUser, $apiKey, $callbackHost); +// 2. Create API Key +$apiKey = $momo->sandbox($subscriptionKey)->createApiKey($apiUser); -// Définir la configuration sur l'instance de MomoApi -$momo->setupCollection($config); +// Now use these credentials for Collection/Disbursement ``` -#### Obtenir un token oauth +## Advanced Usage -```php -$token = $momo->collection()->getAccessToken(); +### Collection API - Full Example -echo $token->getAccessToken(); // Token -echo $token->getExpiresIn(); // Date d'expiration du token +```php +use Lepresk\MomoApi\MomoApi; +use Lepresk\MomoApi\Models\PaymentRequest; + +$collection = MomoApi::collection([ + 'environment' => 'mtncongo', + 'subscription_key' => env('MOMO_SUBSCRIPTION_KEY'), + 'api_user' => env('MOMO_API_USER'), + 'api_key' => env('MOMO_API_KEY'), + 'callback_url' => 'https://yourdomain.com/webhook/momo' +]); + +// Custom payment request +$request = new PaymentRequest( + amount: '2500', + currency: 'XAF', + externalId: 'ORDER-456', + payer: '242068511358', + payerMessage: 'Payment for order #456', + payeeNote: 'Thank you for your purchase' +); + +$paymentId = $collection->requestToPay($request); + +// Get account balance +$balance = $collection->getBalance(); +echo "Available: {$balance->getAvailableBalance()} {$balance->getCurrency()}"; ``` -> _Pour faire une requête requestToPay ou vérifier le statut de la transaction, vous n'avez pas besoin de demander un token, il est automatiquement généré à chaque transaction_ - -#### Récupérer le solde du compte +### Disbursement API - Full Example ```php -$balance = $momo->collection()->getAccountBalance(); - -echo $balance->getAvailableBalance(); // Solde du compte -echo $balance->getCurrency(); // Devise du compte +use Lepresk\MomoApi\MomoApi; +use Lepresk\MomoApi\Models\TransferRequest; +use Lepresk\MomoApi\Models\RefundRequest; + +$disbursement = MomoApi::disbursement([...config...]); + +// Transfer +$transfer = new TransferRequest( + amount: '10000', + currency: 'XAF', + externalId: 'PAYOUT-789', + payee: '242068511358', + payerMessage: 'Monthly salary', + payeeNote: 'Salary payment for June' +); +$transferId = $disbursement->transfer($transfer); + +// Refund +$refund = RefundRequest::make('1000', $originalTransactionId, 'REFUND-123'); +$refundId = $disbursement->refund($refund); + +// Check balance +$balance = $disbursement->getBalance(); ``` -#### Faire une requête requestToPay +### Handling Callbacks ```php collection()->requestToPay($request); -``` +// Parse callback data +$transaction = Transaction::parse($_GET); -> Pour obtenir les numéros de téléphones de test, veuillez vous référer à [https://momodeveloper.mtn.com/api-documentation/testing/](https://momodeveloper.mtn.com/api-documentation/testing/) +if ($transaction->isSuccessful()) { + // Update your database + $orderId = $transaction->getExternalId(); + $amount = $transaction->getAmount(); -`$paymentId` est l'id du paiement qui vient d'être éffectuer, vous pouvez l'enregistrer dans votre base de données pour l'utiliser plus tard (vérifier le statut du paiement par exemple) + // Process order... +} elseif ($transaction->isFailed()) { + $reason = $transaction->getReason(); + echo "Failed: {$reason->getCode()} - {$reason->getMessage()}"; +} +``` -#### Vérifier le status d'une transaction +### Error Handling ```php -collection()->checkRequestStatus($paymentId); +use Lepresk\MomoApi\Exceptions\ResourceNotFoundException; +use Lepresk\MomoApi\Exceptions\InternalServerErrorException; +use Lepresk\MomoApi\Models\ErrorReason; + +try { + $paymentId = $collection->quickPay('1000', '242068511358', 'ORDER-999'); +} catch (ResourceNotFoundException $e) { + // Payment not found + echo "Error: " . $e->getMessage(); +} catch (InternalServerErrorException $e) { + // Server error + echo "Server error, please retry"; +} + +// Check error reason from transaction +$transaction = $collection->getPaymentStatus($paymentId); +if ($transaction->isFailed()) { + $reason = $transaction->getReason(); + + if ($reason->isNotEnoughFunds()) { + echo "Insufficient funds"; + } elseif ($reason->isPayerLimitReached()) { + echo "Transaction limit exceeded"; + } +} +``` -echo $transaction->getStatus(); // Pour obtenir le statut de la transaction +## Available Environments + +| Constant | Value | Use Case | +|----------|-------|----------| +| `ENVIRONMENT_SANDBOX` | sandbox | Testing | +| `ENVIRONMENT_MTN_CONGO` | mtncongo | Production - Congo | +| `ENVIRONMENT_MTN_UGANDA` | mtnuganda | Production - Uganda | +| `ENVIRONMENT_MTN_GHANA` | mtnghana | Production - Ghana | +| `ENVIRONMENT_IVORY_COAST` | mtnivorycoast | Production - Ivory Coast | +| `ENVIRONMENT_ZAMBIA` | mtnzambia | Production - Zambia | +| `ENVIRONMENT_CAMEROON` | mtncameroon | Production - Cameroon | +| `ENVIRONMENT_BENIN` | mtnbenin | Production - Benin | +| `ENVIRONMENT_SWAZILAND` | mtnswaziland | Production - Swaziland | +| `ENVIRONMENT_GUINEACONAKRY` | mtnguineaconakry | Production - Guinea Conakry | +| `ENVIRONMENT_SOUTHAFRICA` | mtnsouthafrica | Production - South Africa | +| `ENVIRONMENT_LIBERIA` | mtnliberia | Production - Liberia | + +## API Reference + +### Collection API + +| Method | Description | +|--------|-------------| +| `requestToPay(PaymentRequest $request)` | Request payment from customer | +| `quickPay(string $amount, string $phone, string $ref)` | Quick payment helper | +| `getPaymentStatus(string $paymentId)` | Check payment status | +| `getBalance()` | Get account balance | +| `getAccessToken()` | Get OAuth token (auto-managed) | + +### Disbursement API + +| Method | Description | +|--------|-------------| +| `transfer(TransferRequest $request)` | Transfer money to beneficiary | +| `getTransferStatus(string $transferId)` | Check transfer status | +| `deposit(PaymentRequest $request)` | Deposit funds | +| `getDepositStatus(string $depositId)` | Check deposit status | +| `refund(RefundRequest $request)` | Refund a transaction | +| `getRefundStatus(string $refundId)` | Check refund status | +| `getBalance()` | Get account balance | +| `getAccessToken()` | Get OAuth token (auto-managed) | + +### Sandbox API + +| Method | Description | +|--------|-------------| +| `createApiUser(string $uuid, string $callback)` | Create sandbox API user | +| `getApiUser(string $uuid)` | Get API user details | +| `createApiKey(string $uuid)` | Generate API key | + +## Testing + +The package includes two types of tests: + +**Unit Tests** - Fast tests with mocked HTTP responses: +```bash +composer test +# or run specific suite +vendor/bin/phpunit --testsuite Unit ``` -#### Gérer le hook du callback +**Fixture Tests** - Validate parsing of real MTN API responses: +```bash +vendor/bin/phpunit --testsuite Fixtures +``` -```php -getStatus(); // Pour obtenir le statut de la transaction -echo $transaction->getAmount(); // Pour récuperer le montant de la transaction -``` +- **Never hardcode credentials** - Use environment variables +- **Validate callbacks** - Check transaction status via API, not just callback data +- **Handle webhooks asynchronously** - Process in background queue +- **Log all transactions** - Keep audit trail +- **Test thoroughly in sandbox** before going live -## Documentation supplémentaire +## Contributing -Pour plus d'informations sur l'utilisation de la librairie **lepresk/momo-api** et les fonctionnalités disponibles, -veuillez consulter la documentation officielle dans le dossier "docs" du dépôt GitHub. +Contributions are welcome! Please create an issue or pull request on [GitHub](https://github.com/lepresk/momo-api). -## Contribution +## License -Les contributions sont les bienvenues ! Si vous souhaitez améliorer la librairie, signalez des problèmes ou soumettez -des demandes de fonctionnalités, veuillez créer une issue sur le dépôt GitHub de la -librairie : [lepresk/momo-api](https://github.com/lepresk/momo-api). +MIT License - see [LICENSE](LICENSE) file for details. -## Licence +## Support -Cette librairie est distribuée sous la licence [MIT](https://opensource.org/licenses/MIT). Vous êtes libre de l'utiliser -et de la modifier selon vos besoins. \ No newline at end of file +- Documentation: [MTN MoMo Developer Portal](https://momodeveloper.mtn.com/) +- Issues: [GitHub Issues](https://github.com/lepresk/momo-api/issues) diff --git a/composer.json b/composer.json index 223e9d5..945d414 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,9 @@ }, "require-dev": { "phpunit/phpunit": "^10.2", - "symfony/var-dumper": "^6.3" + "symfony/var-dumper": "^6.3", + "phpstan/phpstan": "^2.1", + "phpstan/extension-installer": "^1.4" }, "license": "MIT", "autoload": { @@ -37,6 +39,13 @@ } ], "scripts": { - "test": "phpunit --colors=always --testdox" + "test": "phpunit --colors=always --testdox", + "phpstan": "phpstan analyse", + "phpstan-baseline": "phpstan analyse --generate-baseline" + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } } } diff --git a/composer.lock b/composer.lock index 61ed8d7..e053b83 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a20778f1325a8775d0a2619e1e39efac", + "content-hash": "040499052c7e2304352b8eae600a9b8e", "packages": [ { "name": "psr/container", @@ -656,6 +656,107 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.31", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ead89849d879fe203ce9292c6ef5e7e76f867b96", + "reference": "ead89849d879fe203ce9292c6ef5e7e76f867b96", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-10-10T14:14:11+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "10.1.2", @@ -2205,12 +2306,12 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^7.4|^8.0" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b2ef0c3 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 5 + paths: + - src + excludePaths: + - vendor + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml index b81b98b..ce83ef4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,8 +5,13 @@ colors="true" > - - ./tests + + ./tests/Unit + ./tests/MomoApiTest.php + ./tests/UuidTest.php + + + ./tests/FixtureTests diff --git a/src/ApiProduct.php b/src/Abstracts/AbstractApiProduct.php similarity index 74% rename from src/ApiProduct.php rename to src/Abstracts/AbstractApiProduct.php index 1811e7f..6baa163 100644 --- a/src/ApiProduct.php +++ b/src/Abstracts/AbstractApiProduct.php @@ -1,20 +1,17 @@ client = $client; @@ -26,4 +23,4 @@ public function getSubscriptionKey(): string { return $this->config->getSubscriptionKey(); } -} \ No newline at end of file +} diff --git a/src/Exceptions/BadRessourceExeption.php b/src/Exceptions/BadResourceException.php similarity index 81% rename from src/Exceptions/BadRessourceExeption.php rename to src/Exceptions/BadResourceException.php index d1b273d..8b2a461 100644 --- a/src/Exceptions/BadRessourceExeption.php +++ b/src/Exceptions/BadResourceException.php @@ -3,7 +3,7 @@ namespace Lepresk\MomoApi\Exceptions; -class BadRessourceExeption extends MomoException +class BadResourceException extends MomoException { public function __construct() diff --git a/src/Exceptions/ExceptionFactory.php b/src/Exceptions/ExceptionFactory.php index df6d1cb..bb4f79b 100644 --- a/src/Exceptions/ExceptionFactory.php +++ b/src/Exceptions/ExceptionFactory.php @@ -17,7 +17,7 @@ public static function create(ResponseInterface $response): MomoException case 401: return new InvalidSubscriptionKeyException($content['message'] ?? null); case 404: - return new RessourceNotFoundException($content['message'] ?? '', $response->getStatusCode()); + return new ResourceNotFoundException($content['message'] ?? '', $response->getStatusCode()); case 409: return new ConflictException($content['message'] ?? null); case 500: diff --git a/src/Exceptions/RessourceNotFoundException.php b/src/Exceptions/ResourceNotFoundException.php similarity index 82% rename from src/Exceptions/RessourceNotFoundException.php rename to src/Exceptions/ResourceNotFoundException.php index b28a323..c00e05d 100644 --- a/src/Exceptions/RessourceNotFoundException.php +++ b/src/Exceptions/ResourceNotFoundException.php @@ -3,7 +3,7 @@ namespace Lepresk\MomoApi\Exceptions; -class RessourceNotFoundException extends MomoException +class ResourceNotFoundException extends MomoException { public function __construct(?string $message, int $code = 404) { diff --git a/src/ApiToken.php b/src/Models/ApiToken.php similarity index 96% rename from src/ApiToken.php rename to src/Models/ApiToken.php index dbef823..1cf79ad 100644 --- a/src/ApiToken.php +++ b/src/Models/ApiToken.php @@ -1,7 +1,7 @@ code = $code; + $this->message = $message; + } + + public static function fromArray(array $data): self + { + return new self($data['code'] ?? '', $data['message'] ?? ''); + } + + public function getCode(): string + { + return $this->code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function is(string $code): bool + { + return $this->code === $code; + } + + public function isPayeeNotFound(): bool + { + return $this->is(self::PAYEE_NOT_FOUND); + } + + public function isNotEnoughFunds(): bool + { + return $this->is(self::NOT_ENOUGH_FUNDS); + } + + public function isPayerLimitReached(): bool + { + return $this->is(self::PAYER_LIMIT_REACHED); + } + + public function __toString(): string + { + return "[{$this->code}] {$this->message}"; + } +} diff --git a/src/Models/PaymentRequest.php b/src/Models/PaymentRequest.php index ecb37b2..eb535a7 100644 --- a/src/Models/PaymentRequest.php +++ b/src/Models/PaymentRequest.php @@ -6,14 +6,14 @@ class PaymentRequest { /** - * @var float + * @var string */ - private $amount; + private string $amount; /** * @var string */ - private $currency; + private string $currency; /** * @var string @@ -38,15 +38,21 @@ class PaymentRequest private $payerNote; /** - * @param float $amount + * @param string $amount * @param string $currency * @param string $externalId * @param string $payer - * @param $payerMessage - * @param $payerNote + * @param string $payerMessage + * @param string $payerNote */ - public function __construct(float $amount, string $currency, string $externalId, string $payer, $payerMessage, $payerNote) - { + public function __construct( + string $amount, + string $currency, + string $externalId, + string $payer, + string $payerMessage = '', + string $payerNote = '' + ) { $this->amount = $amount; $this->currency = $currency; $this->externalId = $externalId; @@ -56,9 +62,31 @@ public function __construct(float $amount, string $currency, string $externalId, } /** - * @return float + * Static factory with sensible defaults + * + * @param string $amount + * @param string $payer + * @param string $externalId + * @param string $currency + * @param string $payerMessage + * @param string $payeeNote + * @return self + */ + public static function make( + string $amount, + string $payer, + string $externalId, + string $currency = 'XAF', + string $payerMessage = '', + string $payeeNote = '' + ): self { + return new self($amount, $currency, $externalId, $payer, $payerMessage, $payeeNote); + } + + /** + * @return string */ - public function getAmount(): float + public function getAmount(): string { return $this->amount; } diff --git a/src/Models/RefundRequest.php b/src/Models/RefundRequest.php new file mode 100644 index 0000000..87197b2 --- /dev/null +++ b/src/Models/RefundRequest.php @@ -0,0 +1,144 @@ +amount = $amount; + $this->currency = $currency; + $this->externalId = $externalId; + $this->referenceIdToRefund = $referenceIdToRefund; + $this->payerMessage = $payerMessage; + $this->payeeNote = $payeeNote; + } + + /** + * Static factory with sensible defaults + * + * @param string $amount + * @param string $referenceIdToRefund + * @param string $externalId + * @param string $currency + * @param string $payerMessage + * @param string $payeeNote + * @return self + */ + public static function make( + string $amount, + string $referenceIdToRefund, + string $externalId, + string $currency = 'XAF', + string $payerMessage = '', + string $payeeNote = '' + ): self { + return new self($amount, $currency, $externalId, $referenceIdToRefund, $payerMessage, $payeeNote); + } + + /** + * @return string + */ + public function getAmount(): string + { + return $this->amount; + } + + /** + * @return string + */ + public function getCurrency(): string + { + return $this->currency; + } + + /** + * @return string + */ + public function getExternalId(): string + { + return $this->externalId; + } + + /** + * @return string + */ + public function getReferenceIdToRefund(): string + { + return $this->referenceIdToRefund; + } + + /** + * @return string + */ + public function getPayerMessage(): string + { + return $this->payerMessage; + } + + /** + * @return string + */ + public function getPayeeNote(): string + { + return $this->payeeNote; + } + + public function toArray(): array + { + return [ + "amount" => $this->amount, + "currency" => $this->currency, + "externalId" => $this->externalId, + "payerMessage" => $this->payerMessage, + "payeeNote" => $this->payeeNote, + "referenceIdToRefund" => $this->referenceIdToRefund, + ]; + } +} diff --git a/src/Models/Transaction.php b/src/Models/Transaction.php index 688b646..9471401 100644 --- a/src/Models/Transaction.php +++ b/src/Models/Transaction.php @@ -18,7 +18,7 @@ class Transaction private ?string $payeeNote; private string $status; - private ?string $reason; + private ?ErrorReason $reason; /** * @param string|null $financialTransactionId @@ -29,9 +29,9 @@ class Transaction * @param string|null $payerMessage * @param string|null $payeeNote * @param string $status - * @param string|null $reason + * @param ErrorReason|null $reason */ - public function __construct(?string $financialTransactionId, ?string $externalId, ?string $amount, string $currency, array $payer, ?string $payerMessage, ?string $payeeNote, string $status, ?string $reason) + public function __construct(?string $financialTransactionId, ?string $externalId, ?string $amount, string $currency, array $payer, ?string $payerMessage, ?string $payeeNote, string $status, ?ErrorReason $reason) { $this->financialTransactionId = $financialTransactionId; $this->externalId = $externalId; @@ -53,16 +53,21 @@ public function __construct(?string $financialTransactionId, ?string $externalId */ public static function parse(array $array): Transaction { + $reason = null; + if (isset($array['reason']) && is_array($array['reason'])) { + $reason = ErrorReason::fromArray($array['reason']); + } + return new self( $array['financialTransactionId'] ?? null, $array['externalId'], $array['amount'], $array['currency'], - $array['payer'], + $array['payer'] ?? $array['payee'] ?? [], $array['payerMessage'], $array['payeeNote'], $array['status'], - $array['reason'] ?? null, + $reason, ); } @@ -100,9 +105,9 @@ public function getStatus(): string } /** - * @return string|null + * @return ErrorReason|null */ - public function getReason(): ?string + public function getReason(): ?ErrorReason { return $this->reason; } @@ -147,6 +152,15 @@ public function getPayer(): ?string return $this->payer['partyId'] ?? null; } + /** + * Get payee (beneficiary) phone number + * @return string|null + */ + public function getPayee(): ?string + { + return $this->getPayer(); + } + /** * @return string|null */ diff --git a/src/Models/TransferRequest.php b/src/Models/TransferRequest.php new file mode 100644 index 0000000..9dd7c3b --- /dev/null +++ b/src/Models/TransferRequest.php @@ -0,0 +1,148 @@ +amount = $amount; + $this->currency = $currency; + $this->externalId = $externalId; + $this->payee = $payee; + $this->payerMessage = $payerMessage; + $this->payeeNote = $payeeNote; + } + + /** + * Static factory with sensible defaults + * + * @param string $amount + * @param string $payee + * @param string $externalId + * @param string $currency + * @param string $payerMessage + * @param string $payeeNote + * @return self + */ + public static function make( + string $amount, + string $payee, + string $externalId, + string $currency = 'XAF', + string $payerMessage = '', + string $payeeNote = '' + ): self { + return new self($amount, $currency, $externalId, $payee, $payerMessage, $payeeNote); + } + + /** + * @return string + */ + public function getAmount(): string + { + return $this->amount; + } + + /** + * @return string + */ + public function getCurrency(): string + { + return $this->currency; + } + + /** + * @return string + */ + public function getExternalId(): string + { + return $this->externalId; + } + + /** + * @return string + */ + public function getPayee(): string + { + return $this->payee; + } + + /** + * @return string + */ + public function getPayerMessage(): string + { + return $this->payerMessage; + } + + /** + * @return string + */ + public function getPayeeNote(): string + { + return $this->payeeNote; + } + + public function toArray(): array + { + return [ + "amount" => $this->amount, + "currency" => $this->currency, + "externalId" => $this->externalId, + "payee" => [ + "partyIdType" => "MSISDN", + "partyId" => $this->payee, + ], + "payerMessage" => $this->payerMessage, + "payeeNote" => $this->payeeNote, + ]; + } +} diff --git a/src/MomoApi.php b/src/MomoApi.php index b817f49..e80f805 100644 --- a/src/MomoApi.php +++ b/src/MomoApi.php @@ -4,6 +4,7 @@ namespace Lepresk\MomoApi; use InvalidArgumentException; +use Lepresk\MomoApi\Models\Config; use Lepresk\MomoApi\Products\CollectionApi; use Lepresk\MomoApi\Products\DisbursementApi; use Lepresk\MomoApi\Products\SandboxApi; @@ -33,10 +34,6 @@ class MomoApi private static ?HttpClientInterface $client = null; - private ?Config $collectionConfig = null; - - private ?Config $disbursementConfig = null; - private function __construct(string $environment) { $this->environment = $environment; @@ -67,58 +64,68 @@ public static function getClient(): ?HttpClientInterface */ public static function create(string $environment): MomoApi { - if (static::$client === null) { - static::$client = HttpClient::create([ - 'base_uri' => static::getBaseUrl($environment), + if (self::$client === null) { + self::$client = HttpClient::create([ + 'base_uri' => self::getBaseUrl($environment), ]); } return new self($environment); } - public static function getBaseUrl($environment): string - { - if ($environment === MomoApi::ENVIRONMENT_SANDBOX) { - return self::SANDBOX_URL; - } - return self::PRODUCTION_URL; - } - - public function setupCollection(Config $config): void - { - $this->collectionConfig = $config; - } - - public function setupDisbursement(Config $config): void - { - $this->disbursementConfig = $config; - } - /** - * Momo API Collection factory + * Fluent factory for Collection API * + * @param array $config Configuration array with keys: environment, subscription_key, api_user, api_key, callback_url * @return CollectionApi */ - public function collection(): CollectionApi + public static function collection(array $config): CollectionApi { - if ($this->collectionConfig === null) { - throw new InvalidArgumentException("Collection must be setup with `MomoApi::setupCollection` before call `MomoApi::collection`"); + $environment = $config['environment'] ?? self::ENVIRONMENT_SANDBOX; + $subscriptionKey = $config['subscription_key'] ?? throw new InvalidArgumentException('subscription_key is required'); + $apiUser = $config['api_user'] ?? throw new InvalidArgumentException('api_user is required'); + $apiKey = $config['api_key'] ?? throw new InvalidArgumentException('api_key is required'); + $callbackUrl = $config['callback_url'] ?? ''; + + if (self::$client === null) { + self::$client = HttpClient::create([ + 'base_uri' => self::getBaseUrl($environment), + ]); } - return new CollectionApi(static::$client, $this->environment, $this->collectionConfig); + $configObject = Config::collection($subscriptionKey, $apiUser, $apiKey, $callbackUrl); + return new CollectionApi(self::$client, $environment, $configObject); } /** - * Access to Disbursements product + * Fluent factory for Disbursement API * + * @param array $config Configuration array with keys: environment, subscription_key, api_user, api_key, callback_url * @return DisbursementApi */ - public function disbursement(): DisbursementApi + public static function disbursement(array $config): DisbursementApi { - if ($this->disbursementConfig === null) { - throw new InvalidArgumentException("Disbursement must be setup with `MomoApi::setupDisbursement` before call `MomoApi::disbursement`"); + $environment = $config['environment'] ?? self::ENVIRONMENT_SANDBOX; + $subscriptionKey = $config['subscription_key'] ?? throw new InvalidArgumentException('subscription_key is required'); + $apiUser = $config['api_user'] ?? throw new InvalidArgumentException('api_user is required'); + $apiKey = $config['api_key'] ?? throw new InvalidArgumentException('api_key is required'); + $callbackUrl = $config['callback_url'] ?? ''; + + if (self::$client === null) { + self::$client = HttpClient::create([ + 'base_uri' => self::getBaseUrl($environment), + ]); } - return new DisbursementApi(static::$client, $this->environment, $this->disbursementConfig); + $configObject = Config::disbursement($subscriptionKey, $apiUser, $apiKey, $callbackUrl); + return new DisbursementApi(self::$client, $environment, $configObject); + } + + public static function getBaseUrl($environment): string + { + if ($environment === MomoApi::ENVIRONMENT_SANDBOX) { + return self::SANDBOX_URL; + } + return self::PRODUCTION_URL; } /** @@ -133,6 +140,6 @@ public function sandbox(string $subscriptionKey): SandboxApi throw new InvalidArgumentException("Environment must be " . self::ENVIRONMENT_SANDBOX); } - return new SandboxApi(static::$client, $this->environment, Config::sandbox($subscriptionKey)); + return new SandboxApi(self::$client, $this->environment, Config::sandbox($subscriptionKey)); } } \ No newline at end of file diff --git a/src/Products/CollectionApi.php b/src/Products/CollectionApi.php index f6c415b..6d1daff 100644 --- a/src/Products/CollectionApi.php +++ b/src/Products/CollectionApi.php @@ -3,23 +3,22 @@ namespace Lepresk\MomoApi\Products; -use Lepresk\MomoApi\ApiProduct; -use Lepresk\MomoApi\ApiToken; +use Lepresk\MomoApi\Abstracts\AbstractApiProduct; use Lepresk\MomoApi\Exceptions\ExceptionFactory; use Lepresk\MomoApi\Exceptions\MomoException; use Lepresk\MomoApi\Models\AccountBalance; +use Lepresk\MomoApi\Models\ApiToken; use Lepresk\MomoApi\Models\PaymentRequest; use Lepresk\MomoApi\Models\Transaction; -use Lepresk\MomoApi\Utilities; +use Lepresk\MomoApi\Support\Uuid; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; -class CollectionApi extends ApiProduct +class CollectionApi extends AbstractApiProduct { - /** * Request a payment from a consumer (Payer). The payer will be asked to authorize the payment. * The transaction will be executed once the payer has authorized the payment. @@ -49,20 +48,27 @@ class CollectionApi extends ApiProduct */ public function requestToPay(PaymentRequest $paymentRequest): string { - $xReferenceId = Utilities::guidv4(); + $xReferenceId = Uuid::v4(); $token = $this->getAccessToken(); + $headers = [ + 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), + 'X-Reference-Id' => $xReferenceId, + 'X-Target-Environment' => $this->environment, + 'Authorization' => 'Bearer ' . $token->getAccessToken(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + // Add X-Callback-Url if configured + if (!empty($this->config->getCallbackUri())) { + $headers['X-Callback-Url'] = $this->config->getCallbackUri(); + } + $response = $this->client->request('POST', '/collection/v1_0/requesttopay', [ 'json' => $paymentRequest->toArray(), - 'headers' => [ - 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), - 'X-Reference-Id' => $xReferenceId, - 'X-Target-Environment' => $this->environment, - 'Authorization' => 'Bearer ' . $token->getAccessToken(), - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ] + 'headers' => $headers ]); $responseCode = $response->getStatusCode(); @@ -123,7 +129,7 @@ public function getAccessToken(): ApiToken * $result->getAmount(); // 1500 * $result->getPayer(); // 46733123454 * } - * } catch (BadRessourceExeption|InternalServerErrorException|RessourceNotFoundException $e) { + * } catch (BadResourceException|InternalServerErrorException|ResourceNotFoundException $e) { * // Request failed, do something else * } * ``` @@ -137,7 +143,7 @@ public function getAccessToken(): ApiToken * @throws TransportExceptionInterface * @throws MomoException */ - public function checkRequestStatus(string $paymentId): Transaction + public function getPaymentStatus(string $paymentId): Transaction { $token = $this->getAccessToken(); $response = $this->client->request('GET', '/collection/v1_0/requesttopay/' . $paymentId, [ @@ -148,7 +154,8 @@ public function checkRequestStatus(string $paymentId): Transaction ] ]); - if ($response->getStatusCode() === 200) { + $statusCode = $response->getStatusCode(); + if ($statusCode === 200 || $statusCode === 202) { return Transaction::parse($response->toArray()); } @@ -158,6 +165,14 @@ public function checkRequestStatus(string $paymentId): Transaction /** * Get the balance of own account. * + * ### Sample usage + * + * ``` + * $balance = $collection->getBalance(); + * echo $balance->getAvailableBalance(); // 50000 + * echo $balance->getCurrency(); // EUR + * ``` + * * @return AccountBalance * @throws ClientExceptionInterface * @throws DecodingExceptionInterface @@ -166,7 +181,7 @@ public function checkRequestStatus(string $paymentId): Transaction * @throws ServerExceptionInterface * @throws TransportExceptionInterface */ - public function getAccountBalance(): AccountBalance + public function getBalance(): AccountBalance { $token = $this->getAccessToken(); $response = $this->client->request('GET', '/collection/v1_0/account/balance', [ @@ -183,4 +198,46 @@ public function getAccountBalance(): AccountBalance throw ExceptionFactory::create($response); } + + /** + * Quick payment helper with sensible defaults + * + * ### Sample usage + * + * ``` + * $paymentId = $collection->quickPay('1000', '242068511358', 'ORDER-123'); + * // Equivalent to: + * // $request = new PaymentRequest('1000', 'XAF', 'ORDER-123', '242068511358', '', ''); + * // $collection->requestToPay($request); + * ``` + * + * @param string $amount + * @param string $phone + * @param string $reference + * @param string $currency + * @return string payment ID + * @throws ClientExceptionInterface + * @throws DecodingExceptionInterface + * @throws MomoException + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + public function quickPay( + string $amount, + string $phone, + string $reference, + string $currency = 'XAF' + ): string { + $request = new PaymentRequest( + $amount, + $currency, + $reference, + $phone, + '', + '' + ); + + return $this->requestToPay($request); + } } \ No newline at end of file diff --git a/src/Products/DisbursementApi.php b/src/Products/DisbursementApi.php index 7d9eaea..13cfa09 100644 --- a/src/Products/DisbursementApi.php +++ b/src/Products/DisbursementApi.php @@ -2,23 +2,24 @@ namespace Lepresk\MomoApi\Products; -use Lepresk\MomoApi\ApiProduct; -use Lepresk\MomoApi\ApiToken; +use Lepresk\MomoApi\Abstracts\AbstractApiProduct; use Lepresk\MomoApi\Exceptions\ExceptionFactory; use Lepresk\MomoApi\Exceptions\MomoException; use Lepresk\MomoApi\Models\AccountBalance; +use Lepresk\MomoApi\Models\ApiToken; use Lepresk\MomoApi\Models\PaymentRequest; +use Lepresk\MomoApi\Models\RefundRequest; +use Lepresk\MomoApi\Models\TransferRequest; use Lepresk\MomoApi\Models\Transaction; -use Lepresk\MomoApi\Utilities; +use Lepresk\MomoApi\Support\Uuid; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; -class DisbursementApi extends ApiProduct +class DisbursementApi extends AbstractApiProduct { - /** * Create an access token which can then be used to authorize and authenticate * towards the other end-points of the API. @@ -58,6 +59,14 @@ public function getAccessToken(): ApiToken /** * Get the balance of own account. * + * ### Sample usage + * + * ``` + * $balance = $disbursement->getBalance(); + * echo $balance->getAvailableBalance(); // 1000000 + * echo $balance->getCurrency(); // XAF + * ``` + * * @return AccountBalance * @throws ClientExceptionInterface * @throws DecodingExceptionInterface @@ -66,7 +75,7 @@ public function getAccessToken(): ApiToken * @throws ServerExceptionInterface * @throws TransportExceptionInterface */ - public function getAccountBalance(): AccountBalance + public function getBalance(): AccountBalance { $token = $this->getAccessToken(); @@ -86,7 +95,7 @@ public function getAccountBalance(): AccountBalance } /** - * Deposit an amount from the owner’s account to a payee account. + * Deposit an amount from the owner's account to a payee account. * * ### Sample usage * @@ -99,7 +108,7 @@ public function getAccountBalance(): AccountBalance * 'Payment message', * 'A note', * ); - * $paymentId = $momo->disbursement()->requestToPay($request); + * $paymentId = $momo->disbursement()->deposit($request); * ``` * @param PaymentRequest $paymentRequest * @return string payment reference id @@ -110,22 +119,29 @@ public function getAccountBalance(): AccountBalance * @throws ServerExceptionInterface * @throws TransportExceptionInterface */ - public function getDepositV1(PaymentRequest $paymentRequest): string + public function deposit(PaymentRequest $paymentRequest): string { $token = $this->getAccessToken(); - $xReferenceId = Utilities::guidv4(); + $xReferenceId = Uuid::v4(); + + $headers = [ + 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), + 'X-Reference-Id' => $xReferenceId, + 'X-Target-Environment' => $this->environment, + 'Authorization' => 'Bearer ' . $token->getAccessToken(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; - $response = $this->client->request('POST', '/disbursement/v1_0/account/deposit', [ + // Add X-Callback-Url if configured + if (!empty($this->config->getCallbackUri())) { + $headers['X-Callback-Url'] = $this->config->getCallbackUri(); + } + + $response = $this->client->request('POST', '/disbursement/v1_0/deposit', [ 'json' => $paymentRequest->toArray(), - 'headers' => [ - 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), - 'X-Reference-Id' => $xReferenceId, - 'X-Target-Environment' => $this->environment, - 'Authorization' => 'Bearer ' . $token->getAccessToken(), - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ] + 'headers' => $headers ]); $responseCode = $response->getStatusCode(); @@ -150,7 +166,7 @@ public function getDepositV1(PaymentRequest $paymentRequest): string * $result->getAmount(); // 1500 * $result->getPayer(); // 46733123454 * } - * } catch (BadRessourceExeption|InternalServerErrorException|RessourceNotFoundException $e) { + * } catch (BadResourceException|InternalServerErrorException|ResourceNotFoundException $e) { * // Request failed, do something else * } * ``` @@ -175,7 +191,210 @@ public function getDepositStatus(string $depositId): Transaction ] ]); - if ($response->getStatusCode() === 200) { + $statusCode = $response->getStatusCode(); + if ($statusCode === 200 || $statusCode === 202) { + return Transaction::parse($response->toArray()); + } + + throw ExceptionFactory::create($response); + } + + /** + * Transfer an amount from the owner's account to a payee account. + * + * ### Sample usage + * + * ``` + * $request = \Lepresk\MomoApi\Models\TransferRequest::make( + * '1000', + * '242068511358', + * 'TRANSFER-001' + * ); + * $transferId = $momo->disbursement()->transfer($request); + * ``` + * @param TransferRequest $transferRequest + * @return string transfer reference id + * @throws ClientExceptionInterface + * @throws DecodingExceptionInterface + * @throws MomoException + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + public function transfer(TransferRequest $transferRequest): string + { + $token = $this->getAccessToken(); + + $xReferenceId = Uuid::v4(); + + $headers = [ + 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), + 'X-Reference-Id' => $xReferenceId, + 'X-Target-Environment' => $this->environment, + 'Authorization' => 'Bearer ' . $token->getAccessToken(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + // Add X-Callback-Url if configured + if (!empty($this->config->getCallbackUri())) { + $headers['X-Callback-Url'] = $this->config->getCallbackUri(); + } + + $response = $this->client->request('POST', '/disbursement/v1_0/transfer', [ + 'json' => $transferRequest->toArray(), + 'headers' => $headers + ]); + + $responseCode = $response->getStatusCode(); + if ($responseCode === 202) { + return $xReferenceId; + } + + throw ExceptionFactory::create($response); + } + + /** + * Get the status of a transfer. X-Reference-Id that was passed in the post is used as reference to the request. + * + * ### Sample usage + * + * ``` + * $transferId = "07a461a4-e721-462b-81c6-b9aa2f8abf06"; + * try { + * $result = $momo->disbursement()->getTransferStatus($transferId); + * if($result->isSuccessful()) { + * echo "Transfer successful"; + * $result->getAmount(); // 1500 + * } + * } catch (BadResourceException|InternalServerErrorException|ResourceNotFoundException $e) { + * // Request failed, do something else + * } + * ``` + * + * @param string $transferId UUID of transaction to get result. Reference id used when creating the transfer. + * @return Transaction + * @throws ClientExceptionInterface + * @throws DecodingExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + * @throws MomoException + */ + public function getTransferStatus(string $transferId): Transaction + { + $token = $this->getAccessToken(); + $response = $this->client->request('GET', '/disbursement/v1_0/transfer/' . $transferId, [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), + 'X-Target-Environment' => $this->environment, + 'Authorization' => 'Bearer ' . $token->getAccessToken(), + ] + ]); + + $statusCode = $response->getStatusCode(); + if ($statusCode === 200 || $statusCode === 202) { + return Transaction::parse($response->toArray()); + } + + throw ExceptionFactory::create($response); + } + + /** + * Refund an amount to the payer. + * + * ### Sample usage + * + * ``` + * $request = \Lepresk\MomoApi\Models\RefundRequest::make( + * '1000', + * '07a461a4-e721-462b-81c6-b9aa2f8abf06', // Original transaction ID + * 'REFUND-001' + * ); + * $refundId = $momo->disbursement()->refund($request); + * ``` + * @param RefundRequest $refundRequest + * @return string refund reference id + * @throws ClientExceptionInterface + * @throws DecodingExceptionInterface + * @throws MomoException + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + */ + public function refund(RefundRequest $refundRequest): string + { + $token = $this->getAccessToken(); + + $xReferenceId = Uuid::v4(); + + $headers = [ + 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), + 'X-Reference-Id' => $xReferenceId, + 'X-Target-Environment' => $this->environment, + 'Authorization' => 'Bearer ' . $token->getAccessToken(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + // Add X-Callback-Url if configured + if (!empty($this->config->getCallbackUri())) { + $headers['X-Callback-Url'] = $this->config->getCallbackUri(); + } + + $response = $this->client->request('POST', '/disbursement/v1_0/refund', [ + 'json' => $refundRequest->toArray(), + 'headers' => $headers + ]); + + $responseCode = $response->getStatusCode(); + if ($responseCode === 202) { + return $xReferenceId; + } + + throw ExceptionFactory::create($response); + } + + /** + * Get the status of a refund. X-Reference-Id that was passed in the post is used as reference to the request. + * + * ### Sample usage + * + * ``` + * $refundId = "07a461a4-e721-462b-81c6-b9aa2f8abf06"; + * try { + * $result = $momo->disbursement()->getRefundStatus($refundId); + * if($result->isSuccessful()) { + * echo "Refund successful"; + * $result->getAmount(); // 1500 + * } + * } catch (BadResourceException|InternalServerErrorException|ResourceNotFoundException $e) { + * // Request failed, do something else + * } + * ``` + * + * @param string $refundId UUID of transaction to get result. Reference id used when creating the refund. + * @return Transaction + * @throws ClientExceptionInterface + * @throws DecodingExceptionInterface + * @throws RedirectionExceptionInterface + * @throws ServerExceptionInterface + * @throws TransportExceptionInterface + * @throws MomoException + */ + public function getRefundStatus(string $refundId): Transaction + { + $token = $this->getAccessToken(); + $response = $this->client->request('GET', '/disbursement/v1_0/refund/' . $refundId, [ + 'headers' => [ + 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), + 'X-Target-Environment' => $this->environment, + 'Authorization' => 'Bearer ' . $token->getAccessToken(), + ] + ]); + + $statusCode = $response->getStatusCode(); + if ($statusCode === 200 || $statusCode === 202) { return Transaction::parse($response->toArray()); } diff --git a/src/Products/SandboxApi.php b/src/Products/SandboxApi.php index 98674f1..263e40f 100644 --- a/src/Products/SandboxApi.php +++ b/src/Products/SandboxApi.php @@ -3,7 +3,7 @@ namespace Lepresk\MomoApi\Products; -use Lepresk\MomoApi\ApiProduct; +use Lepresk\MomoApi\Abstracts\AbstractApiProduct; use Lepresk\MomoApi\Exceptions\ExceptionFactory; use Lepresk\MomoApi\Exceptions\MomoException; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; @@ -12,7 +12,7 @@ use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; -class SandboxApi extends ApiProduct +class SandboxApi extends AbstractApiProduct { /** diff --git a/src/Utilities.php b/src/Support/Uuid.php similarity index 83% rename from src/Utilities.php rename to src/Support/Uuid.php index 3f68b81..2f008b4 100644 --- a/src/Utilities.php +++ b/src/Support/Uuid.php @@ -1,11 +1,11 @@ assertInstanceOf(ApiToken::class, $token); + $this->assertStringStartsWith('eyJ', $token->getAccessToken()); + $this->assertEquals('Bearer', $token->getTokenType()); + $this->assertEquals(3600, $token->getExpiresIn()); + } + + public function testParsePaymentPending() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Collection/payment_pending.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('PENDING', $transaction->getStatus()); + $this->assertEquals('1000', $transaction->getAmount()); + $this->assertEquals('EUR', $transaction->getCurrency()); + $this->assertEquals('46733123454', $transaction->getPayer()); + $this->assertFalse($transaction->isSuccessful()); + $this->assertFalse($transaction->isFailed()); + $this->assertNull($transaction->getReason()); + } + + public function testParsePaymentSuccessful() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Collection/payment_successful.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('SUCCESSFUL', $transaction->getStatus()); + $this->assertEquals('2500', $transaction->getAmount()); + $this->assertEquals('EUR', $transaction->getCurrency()); + $this->assertEquals('46733123453', $transaction->getPayer()); + $this->assertEquals('987654321', $transaction->getFinancialTransactionId()); + $this->assertTrue($transaction->isSuccessful()); + $this->assertNull($transaction->getReason()); + } + + public function testParsePaymentFailedNotEnoughFunds() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Collection/payment_failed_not_enough_funds.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('FAILED', $transaction->getStatus()); + $this->assertEquals('5000', $transaction->getAmount()); + $this->assertEquals('XAF', $transaction->getCurrency()); + $this->assertTrue($transaction->isFailed()); + $this->assertFalse($transaction->isSuccessful()); + + $this->assertInstanceOf(ErrorReason::class, $transaction->getReason()); + $this->assertEquals('NOT_ENOUGH_FUNDS', $transaction->getReason()->getCode()); + $this->assertTrue($transaction->getReason()->isNotEnoughFunds()); + } + + public function testParsePaymentFailedPayerLimit() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Collection/payment_failed_payer_limit.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('FAILED', $transaction->getStatus()); + $this->assertTrue($transaction->isFailed()); + + $this->assertInstanceOf(ErrorReason::class, $transaction->getReason()); + $this->assertEquals('PAYER_LIMIT_REACHED', $transaction->getReason()->getCode()); + $this->assertTrue($transaction->getReason()->isPayerLimitReached()); + $this->assertFalse($transaction->getReason()->isNotEnoughFunds()); + } + + public function testParseBalance() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Collection/balance.json'); + $data = json_decode($json, true); + + $balance = AccountBalance::parse($data); + + $this->assertEquals('50000', $balance->getAvailableBalance()); + $this->assertEquals('EUR', $balance->getCurrency()); + } + + public function testParseErrorResourceNotFound() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Collection/error_resource_not_found.json'); + $data = json_decode($json, true); + + $error = ErrorReason::fromArray($data); + + $this->assertEquals('RESOURCE_NOT_FOUND', $error->getCode()); + $this->assertEquals('Requested resource was not found.', $error->getMessage()); + } +} diff --git a/tests/FixtureTests/DisbursementFixtureTest.php b/tests/FixtureTests/DisbursementFixtureTest.php new file mode 100644 index 0000000..be64e58 --- /dev/null +++ b/tests/FixtureTests/DisbursementFixtureTest.php @@ -0,0 +1,151 @@ +assertInstanceOf(ApiToken::class, $token); + $this->assertStringStartsWith('eyJ', $token->getAccessToken()); + $this->assertEquals('Bearer', $token->getTokenType()); + $this->assertEquals(3600, $token->getExpiresIn()); + } + + public function testParseTransferSuccessful() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/transfer_successful.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('SUCCESSFUL', $transaction->getStatus()); + $this->assertEquals('100', $transaction->getAmount()); + $this->assertEquals('UGX', $transaction->getCurrency()); + $this->assertEquals('4609274685', $transaction->getPayee()); + $this->assertEquals('363440463', $transaction->getFinancialTransactionId()); + $this->assertTrue($transaction->isSuccessful()); + $this->assertNull($transaction->getReason()); + } + + public function testParseTransferFailedLimitReached() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/transfer_failed_limit_reached.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('FAILED', $transaction->getStatus()); + $this->assertTrue($transaction->isFailed()); + $this->assertFalse($transaction->isSuccessful()); + + $this->assertInstanceOf(ErrorReason::class, $transaction->getReason()); + $this->assertEquals('PAYER_LIMIT_REACHED', $transaction->getReason()->getCode()); + $this->assertTrue($transaction->getReason()->isPayerLimitReached()); + } + + public function testParseTransferFailedNotEnoughFunds() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/transfer_failed_not_enough_funds.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('FAILED', $transaction->getStatus()); + $this->assertTrue($transaction->isFailed()); + + $this->assertInstanceOf(ErrorReason::class, $transaction->getReason()); + $this->assertEquals('NOT_ENOUGH_FUNDS', $transaction->getReason()->getCode()); + $this->assertTrue($transaction->getReason()->isNotEnoughFunds()); + $this->assertFalse($transaction->getReason()->isPayerLimitReached()); + } + + public function testParseTransferPending() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/transfer_pending.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('PENDING', $transaction->getStatus()); + $this->assertEquals('250', $transaction->getAmount()); + $this->assertEquals('XAF', $transaction->getCurrency()); + $this->assertEquals('242068511358', $transaction->getPayee()); + $this->assertFalse($transaction->isSuccessful()); + $this->assertFalse($transaction->isFailed()); + $this->assertNull($transaction->getReason()); + } + + public function testParseDepositSuccessful() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/deposit_successful.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('SUCCESSFUL', $transaction->getStatus()); + $this->assertEquals('500', $transaction->getAmount()); + $this->assertEquals('EUR', $transaction->getCurrency()); + $this->assertEquals('46733123454', $transaction->getPayee()); + $this->assertTrue($transaction->isSuccessful()); + } + + public function testParseRefundSuccessful() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/refund_successful.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('SUCCESSFUL', $transaction->getStatus()); + $this->assertEquals('100', $transaction->getAmount()); + $this->assertEquals('UGX', $transaction->getCurrency()); + $this->assertTrue($transaction->isSuccessful()); + } + + public function testParseRefundPending() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/refund_pending.json'); + $data = json_decode($json, true); + + $transaction = Transaction::parse($data); + + $this->assertEquals('PENDING', $transaction->getStatus()); + $this->assertEquals('150', $transaction->getAmount()); + $this->assertFalse($transaction->isSuccessful()); + } + + public function testParseBalance() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/balance.json'); + $data = json_decode($json, true); + + $balance = AccountBalance::parse($data); + + $this->assertEquals('1000000', $balance->getAvailableBalance()); + $this->assertEquals('XAF', $balance->getCurrency()); + } + + public function testParseErrorResourceNotFound() + { + $json = file_get_contents(__DIR__ . '/../Fixtures/Disbursement/error_resource_not_found.json'); + $data = json_decode($json, true); + + $error = ErrorReason::fromArray($data); + + $this->assertEquals('RESOURCE_NOT_FOUND', $error->getCode()); + $this->assertEquals('Requested resource was not found.', $error->getMessage()); + } +} diff --git a/tests/FixtureTests/SandboxFixtureTest.php b/tests/FixtureTests/SandboxFixtureTest.php new file mode 100644 index 0000000..a4005d4 --- /dev/null +++ b/tests/FixtureTests/SandboxFixtureTest.php @@ -0,0 +1,19 @@ +assertArrayHasKey('apiKey', $data); + $this->assertIsString($data['apiKey']); + $this->assertEquals(32, strlen($data['apiKey'])); + } +} diff --git a/tests/Fixtures/Collection/balance.json b/tests/Fixtures/Collection/balance.json new file mode 100644 index 0000000..8d806f6 --- /dev/null +++ b/tests/Fixtures/Collection/balance.json @@ -0,0 +1,4 @@ +{ + "availableBalance": "50000", + "currency": "EUR" +} diff --git a/tests/Fixtures/Collection/error_resource_not_found.json b/tests/Fixtures/Collection/error_resource_not_found.json new file mode 100644 index 0000000..99ab8e2 --- /dev/null +++ b/tests/Fixtures/Collection/error_resource_not_found.json @@ -0,0 +1,4 @@ +{ + "code": "RESOURCE_NOT_FOUND", + "message": "Requested resource was not found." +} diff --git a/tests/Fixtures/Collection/payment_failed_not_enough_funds.json b/tests/Fixtures/Collection/payment_failed_not_enough_funds.json new file mode 100644 index 0000000..18d6cd9 --- /dev/null +++ b/tests/Fixtures/Collection/payment_failed_not_enough_funds.json @@ -0,0 +1,16 @@ +{ + "amount": "5000", + "currency": "XAF", + "externalId": "ORDER-789", + "payer": { + "partyIdType": "MSISDN", + "partyId": "242068511358" + }, + "payerMessage": "Payment attempt", + "payeeNote": "", + "status": "FAILED", + "reason": { + "code": "NOT_ENOUGH_FUNDS", + "message": "The payer does not have enough funds." + } +} diff --git a/tests/Fixtures/Collection/payment_failed_payer_limit.json b/tests/Fixtures/Collection/payment_failed_payer_limit.json new file mode 100644 index 0000000..51f0052 --- /dev/null +++ b/tests/Fixtures/Collection/payment_failed_payer_limit.json @@ -0,0 +1,16 @@ +{ + "amount": "10000", + "currency": "XAF", + "externalId": "ORDER-999", + "payer": { + "partyIdType": "MSISDN", + "partyId": "242065599123" + }, + "payerMessage": "", + "payeeNote": "", + "status": "FAILED", + "reason": { + "code": "PAYER_LIMIT_REACHED", + "message": "The payer's limit has been breached." + } +} diff --git a/tests/Fixtures/Collection/payment_pending.json b/tests/Fixtures/Collection/payment_pending.json new file mode 100644 index 0000000..c5586d6 --- /dev/null +++ b/tests/Fixtures/Collection/payment_pending.json @@ -0,0 +1,13 @@ +{ + "amount": "1000", + "currency": "EUR", + "financialTransactionId": "476321816", + "externalId": "ORDER-123", + "payer": { + "partyIdType": "MSISDN", + "partyId": "46733123454" + }, + "payerMessage": "Payment for order", + "payeeNote": "Thank you", + "status": "PENDING" +} diff --git a/tests/Fixtures/Collection/payment_successful.json b/tests/Fixtures/Collection/payment_successful.json new file mode 100644 index 0000000..f8787b2 --- /dev/null +++ b/tests/Fixtures/Collection/payment_successful.json @@ -0,0 +1,13 @@ +{ + "amount": "2500", + "currency": "EUR", + "financialTransactionId": "987654321", + "externalId": "ORDER-456", + "payer": { + "partyIdType": "MSISDN", + "partyId": "46733123453" + }, + "payerMessage": "Payment message", + "payeeNote": "A note", + "status": "SUCCESSFUL" +} diff --git a/tests/Fixtures/Collection/token_success.json b/tests/Fixtures/Collection/token_success.json new file mode 100644 index 0000000..8368d3d --- /dev/null +++ b/tests/Fixtures/Collection/token_success.json @@ -0,0 +1,5 @@ +{ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSMjU2In0.eyJjbGllbnRJZCI6IjJiZjA1OTJjLTQ3NWItNGY2YS05MTJlLWM1ZGY4NjFjMzAyNiIsImV4cGlyZXMiOiIyMDI0LTExLTIzVDE1OjMwOjAwLjAwMCIsInNlc3Npb25JZCI6IjI1NzBmZDIzLTUwZDUtNDQwMi05ODg3LWFkZWY2YzUzYTkxZiJ9.nKAIFzJx1fJsFdJ8YN6hMh0E4GdlJ_EXAMPLE", + "token_type": "Bearer", + "expires_in": 3600 +} diff --git a/tests/Fixtures/Disbursement/balance.json b/tests/Fixtures/Disbursement/balance.json new file mode 100644 index 0000000..abde1c7 --- /dev/null +++ b/tests/Fixtures/Disbursement/balance.json @@ -0,0 +1,4 @@ +{ + "availableBalance": "1000000", + "currency": "XAF" +} diff --git a/tests/Fixtures/Disbursement/deposit_successful.json b/tests/Fixtures/Disbursement/deposit_successful.json new file mode 100644 index 0000000..a1ac9df --- /dev/null +++ b/tests/Fixtures/Disbursement/deposit_successful.json @@ -0,0 +1,13 @@ +{ + "amount": "500", + "currency": "EUR", + "financialTransactionId": "789456123", + "externalId": "DEP-67890", + "payee": { + "partyIdType": "MSISDN", + "partyId": "46733123454" + }, + "payerMessage": "Deposit", + "payeeNote": "Account credit", + "status": "SUCCESSFUL" +} diff --git a/tests/Fixtures/Disbursement/error_resource_not_found.json b/tests/Fixtures/Disbursement/error_resource_not_found.json new file mode 100644 index 0000000..99ab8e2 --- /dev/null +++ b/tests/Fixtures/Disbursement/error_resource_not_found.json @@ -0,0 +1,4 @@ +{ + "code": "RESOURCE_NOT_FOUND", + "message": "Requested resource was not found." +} diff --git a/tests/Fixtures/Disbursement/refund_pending.json b/tests/Fixtures/Disbursement/refund_pending.json new file mode 100644 index 0000000..10f8917 --- /dev/null +++ b/tests/Fixtures/Disbursement/refund_pending.json @@ -0,0 +1,12 @@ +{ + "amount": "150", + "currency": "XAF", + "externalId": "REF-98765", + "payee": { + "partyIdType": "MSISDN", + "partyId": "242068511358" + }, + "payerMessage": "Refund request", + "payeeNote": "Order cancellation", + "status": "PENDING" +} diff --git a/tests/Fixtures/Disbursement/refund_successful.json b/tests/Fixtures/Disbursement/refund_successful.json new file mode 100644 index 0000000..4c0a478 --- /dev/null +++ b/tests/Fixtures/Disbursement/refund_successful.json @@ -0,0 +1,11 @@ +{ + "amount": "100", + "currency": "UGX", + "financialTransactionId": "363440463", + "externalId": "83453", + "payee": { + "partyIdType": "MSISDN", + "partyId": "4609274685" + }, + "status": "SUCCESSFUL" +} diff --git a/tests/Fixtures/Disbursement/token_success.json b/tests/Fixtures/Disbursement/token_success.json new file mode 100644 index 0000000..5459710 --- /dev/null +++ b/tests/Fixtures/Disbursement/token_success.json @@ -0,0 +1,5 @@ +{ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSMjU2In0.eyJjbGllbnRJZCI6ImExYjJjM2Q0LWU1ZjYtNGE1Yi05YzhkLTFlMmYzYTRiNWM2ZCIsImV4cGlyZXMiOiIyMDI0LTExLTI0VDEwOjQ1OjAwLjAwMCIsInNlc3Npb25JZCI6IjU0NzhlOWQyLTY3ZDctNDVhMi1hODg3LWJjOGU1ZjY0YTE4MyJ9.mDxYWvM2kQsJpE_EXAMPLE", + "token_type": "Bearer", + "expires_in": 3600 +} diff --git a/tests/Fixtures/Disbursement/transfer_failed_limit_reached.json b/tests/Fixtures/Disbursement/transfer_failed_limit_reached.json new file mode 100644 index 0000000..6aa1ac5 --- /dev/null +++ b/tests/Fixtures/Disbursement/transfer_failed_limit_reached.json @@ -0,0 +1,14 @@ +{ + "amount": "100", + "currency": "UGX", + "externalId": "83453", + "payee": { + "partyIdType": "MSISDN", + "partyId": "4609274685" + }, + "status": "FAILED", + "reason": { + "code": "PAYER_LIMIT_REACHED", + "message": "The payer's limit has been breached." + } +} diff --git a/tests/Fixtures/Disbursement/transfer_failed_not_enough_funds.json b/tests/Fixtures/Disbursement/transfer_failed_not_enough_funds.json new file mode 100644 index 0000000..45b236e --- /dev/null +++ b/tests/Fixtures/Disbursement/transfer_failed_not_enough_funds.json @@ -0,0 +1,14 @@ +{ + "amount": "100", + "currency": "UGX", + "externalId": "83453", + "payee": { + "partyIdType": "MSISDN", + "partyId": "4609274685" + }, + "status": "FAILED", + "reason": { + "code": "NOT_ENOUGH_FUNDS", + "message": "The payer does not have enough funds." + } +} diff --git a/tests/Fixtures/Disbursement/transfer_pending.json b/tests/Fixtures/Disbursement/transfer_pending.json new file mode 100644 index 0000000..c3a59d2 --- /dev/null +++ b/tests/Fixtures/Disbursement/transfer_pending.json @@ -0,0 +1,13 @@ +{ + "amount": "250", + "currency": "XAF", + "financialTransactionId": "123987456", + "externalId": "TRX-12345", + "payee": { + "partyIdType": "MSISDN", + "partyId": "242068511358" + }, + "payerMessage": "Transfer payment", + "payeeNote": "Salary payment", + "status": "PENDING" +} diff --git a/tests/Fixtures/Disbursement/transfer_successful.json b/tests/Fixtures/Disbursement/transfer_successful.json new file mode 100644 index 0000000..4c0a478 --- /dev/null +++ b/tests/Fixtures/Disbursement/transfer_successful.json @@ -0,0 +1,11 @@ +{ + "amount": "100", + "currency": "UGX", + "financialTransactionId": "363440463", + "externalId": "83453", + "payee": { + "partyIdType": "MSISDN", + "partyId": "4609274685" + }, + "status": "SUCCESSFUL" +} diff --git a/tests/Fixtures/Sandbox/apiuser_key.json b/tests/Fixtures/Sandbox/apiuser_key.json new file mode 100644 index 0000000..5129d78 --- /dev/null +++ b/tests/Fixtures/Sandbox/apiuser_key.json @@ -0,0 +1,3 @@ +{ + "apiKey": "b0f527d4f6f54b7a9b8c3d2e1f0a9b8c" +} diff --git a/tests/MomoApiTest.php b/tests/MomoApiTest.php index a250951..4870a6b 100644 --- a/tests/MomoApiTest.php +++ b/tests/MomoApiTest.php @@ -19,18 +19,19 @@ public function testBaseUrlByEnvironnment() $this->assertEquals(MomoApi::PRODUCTION_URL, $baseUrl); } - public function testFailGetCollectionWithoutConfig() + public function testFailGetCollectionWithoutRequiredConfig() { $this->expectException(InvalidArgumentException::class); - $momo = MomoApi::create(MomoApi::ENVIRONMENT_SANDBOX); - $momo->collection(); + MomoApi::collection([ + 'environment' => 'sandbox', + ]); } public function testFailUseSandboxInProduction() { $this->expectException(InvalidArgumentException::class); $momo = MomoApi::create(MomoApi::ENVIRONMENT_MTN_CONGO); - $momo->sandbox('subscriptionLey'); + $momo->sandbox('subscriptionKey'); } public function testUsingMockedClient() diff --git a/tests/Unit/Models/ErrorReasonTest.php b/tests/Unit/Models/ErrorReasonTest.php new file mode 100644 index 0000000..6a8d105 --- /dev/null +++ b/tests/Unit/Models/ErrorReasonTest.php @@ -0,0 +1,76 @@ +assertEquals('NOT_ENOUGH_FUNDS', $reason->getCode()); + $this->assertEquals('Insufficient balance', $reason->getMessage()); + } + + public function testFromArray() + { + $data = [ + 'code' => 'PAYER_LIMIT_REACHED', + 'message' => 'Transaction limit exceeded' + ]; + + $reason = ErrorReason::fromArray($data); + + $this->assertEquals('PAYER_LIMIT_REACHED', $reason->getCode()); + $this->assertEquals('Transaction limit exceeded', $reason->getMessage()); + } + + public function testIsMethod() + { + $reason = new ErrorReason('NOT_ENOUGH_FUNDS', 'Insufficient balance'); + + $this->assertTrue($reason->is('NOT_ENOUGH_FUNDS')); + $this->assertFalse($reason->is('PAYER_LIMIT_REACHED')); + } + + public function testIsNotEnoughFunds() + { + $reason = new ErrorReason('NOT_ENOUGH_FUNDS', 'Insufficient balance'); + + $this->assertTrue($reason->isNotEnoughFunds()); + $this->assertFalse($reason->isPayerLimitReached()); + } + + public function testIsPayerLimitReached() + { + $reason = new ErrorReason('PAYER_LIMIT_REACHED', 'Limit exceeded'); + + $this->assertTrue($reason->isPayerLimitReached()); + $this->assertFalse($reason->isNotEnoughFunds()); + } + + public function testIsPayeeNotFound() + { + $reason = new ErrorReason('PAYEE_NOT_FOUND', 'Payee not found'); + + $this->assertTrue($reason->isPayeeNotFound()); + } + + public function testToString() + { + $reason = new ErrorReason('NOT_ENOUGH_FUNDS', 'Insufficient balance'); + + $this->assertEquals('[NOT_ENOUGH_FUNDS] Insufficient balance', (string)$reason); + } + + public function testErrorCodeConstants() + { + $this->assertEquals('PAYEE_NOT_FOUND', ErrorReason::PAYEE_NOT_FOUND); + $this->assertEquals('NOT_ENOUGH_FUNDS', ErrorReason::NOT_ENOUGH_FUNDS); + $this->assertEquals('PAYER_LIMIT_REACHED', ErrorReason::PAYER_LIMIT_REACHED); + } +} diff --git a/tests/Unit/Models/RefundRequestTest.php b/tests/Unit/Models/RefundRequestTest.php new file mode 100644 index 0000000..ae7a279 --- /dev/null +++ b/tests/Unit/Models/RefundRequestTest.php @@ -0,0 +1,70 @@ +assertEquals('1000', $request->getAmount()); + $this->assertEquals('XAF', $request->getCurrency()); + $this->assertEquals('REFUND-001', $request->getExternalId()); + $this->assertEquals($originalTxId, $request->getReferenceIdToRefund()); + $this->assertEquals('Refund message', $request->getPayerMessage()); + $this->assertEquals('Refund note', $request->getPayeeNote()); + } + + public function testMakeFactory() + { + $originalTxId = '07a461a4-e721-462b-81c6-b9aa2f8abf06'; + + $request = RefundRequest::make( + '500', + $originalTxId, + 'REFUND-002' + ); + + $this->assertEquals('500', $request->getAmount()); + $this->assertEquals('XAF', $request->getCurrency()); + $this->assertEquals('REFUND-002', $request->getExternalId()); + $this->assertEquals($originalTxId, $request->getReferenceIdToRefund()); + } + + public function testToArray() + { + $originalTxId = 'a1b2c3d4-e5f6-4a5b-9c8d-1e2f3a4b5c6d'; + + $request = new RefundRequest( + '2500', + 'EUR', + 'REF-123', + $originalTxId, + 'Refund message', + 'Refund note' + ); + + $array = $request->toArray(); + + $this->assertEquals('2500', $array['amount']); + $this->assertEquals('EUR', $array['currency']); + $this->assertEquals('REF-123', $array['externalId']); + $this->assertEquals($originalTxId, $array['referenceIdToRefund']); + $this->assertEquals('Refund message', $array['payerMessage']); + $this->assertEquals('Refund note', $array['payeeNote']); + } +} diff --git a/tests/Unit/Models/TransferRequestTest.php b/tests/Unit/Models/TransferRequestTest.php new file mode 100644 index 0000000..d828348 --- /dev/null +++ b/tests/Unit/Models/TransferRequestTest.php @@ -0,0 +1,65 @@ +assertEquals('1000', $request->getAmount()); + $this->assertEquals('XAF', $request->getCurrency()); + $this->assertEquals('TRANSFER-001', $request->getExternalId()); + $this->assertEquals('242068511358', $request->getPayee()); + $this->assertEquals('Salary payment', $request->getPayerMessage()); + $this->assertEquals('Monthly salary', $request->getPayeeNote()); + } + + public function testMakeFactory() + { + $request = TransferRequest::make( + '5000', + '242068511358', + 'TRANSFER-002' + ); + + $this->assertEquals('5000', $request->getAmount()); + $this->assertEquals('XAF', $request->getCurrency()); + $this->assertEquals('TRANSFER-002', $request->getExternalId()); + $this->assertEquals('242068511358', $request->getPayee()); + } + + public function testToArray() + { + $request = new TransferRequest( + '2500', + 'EUR', + 'TRANS-123', + '33612345678', + 'Transfer message', + 'Transfer note' + ); + + $array = $request->toArray(); + + $this->assertEquals('2500', $array['amount']); + $this->assertEquals('EUR', $array['currency']); + $this->assertEquals('TRANS-123', $array['externalId']); + $this->assertEquals('MSISDN', $array['payee']['partyIdType']); + $this->assertEquals('33612345678', $array['payee']['partyId']); + $this->assertEquals('Transfer message', $array['payerMessage']); + $this->assertEquals('Transfer note', $array['payeeNote']); + } +} diff --git a/tests/Products/CollectionApiTest.php b/tests/Unit/Products/CollectionApiTest.php similarity index 61% rename from tests/Products/CollectionApiTest.php rename to tests/Unit/Products/CollectionApiTest.php index 39cf918..4fae228 100644 --- a/tests/Products/CollectionApiTest.php +++ b/tests/Unit/Products/CollectionApiTest.php @@ -1,12 +1,12 @@ provideClient($expectedRequests)); - $momo = MomoApi::create(MomoApi::ENVIRONMENT_SANDBOX); - $momo->setupCollection(Config::collection($subscriptionKey, "apiUser", "apiKey", "aCalllback")); - $momo->collection()->getAccessToken(); + $collection = MomoApi::collection([ + 'environment' => 'sandbox', + 'subscription_key' => $subscriptionKey, + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => 'aCalllback' + ]); + $collection->getAccessToken(); } public function testGetAccessToken() @@ -48,9 +53,14 @@ function ($method, $url, $options) use ($sampleToken): MockResponse { ]; MomoApi::useClient($this->provideClient($expectedRequests)); - $momo = MomoApi::create(MomoApi::ENVIRONMENT_SANDBOX); - $momo->setupCollection(Config::collection('testSubKey', "apiUser", "apiKey", "aCalllback")); - $token = $momo->collection()->getAccessToken(); + $collection = MomoApi::collection([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => 'aCalllback' + ]); + $token = $collection->getAccessToken(); $this->assertEquals($sampleToken['access_token'], $token->getAccessToken()); } @@ -68,9 +78,14 @@ function ($method, $url, $options): MockResponse { $this->expectException(MomoException::class); MomoApi::useClient($this->provideClient($expectedRequests)); - $momo = MomoApi::create(MomoApi::ENVIRONMENT_SANDBOX); - $momo->setupCollection(Config::collection('testSubKey', "apiUser", "apiKey", "aCalllback")); - $momo->collection()->getAccessToken(); + $collection = MomoApi::collection([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => 'aCalllback' + ]); + $collection->getAccessToken(); } public function testRequestToPay() @@ -86,12 +101,17 @@ function ($method, $url, $options): MockResponse { ]; MomoApi::useClient($this->provideClient($expectedRequests)); - $momo = MomoApi::create(MomoApi::ENVIRONMENT_SANDBOX); - $momo->setupCollection(Config::collection('testSubKey', "apiUser", "apiKey", "aCalllback")); + $collection = MomoApi::collection([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => 'aCalllback' + ]); $request = new PaymentRequest(1000, 'EUR', 'ORDER-10', '46733123454', '', ''); - $paymentId = $momo->collection()->requestToPay($request); + $paymentId = $collection->requestToPay($request); $this->assertValidGuidV4($paymentId); } @@ -118,18 +138,23 @@ function (): MockResponse { ]; MomoApi::useClient($this->provideClient($expectedRequests)); - $momo = MomoApi::create(MomoApi::ENVIRONMENT_SANDBOX); - $momo->setupCollection(Config::collection('testSubKey', "apiUser", "apiKey", "aCalllback")); + $collection = MomoApi::collection([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => 'aCalllback' + ]); $request = new PaymentRequest(1000, 'EUR', 'ORDER-10', '46733123454', '', ''); $this->expectException(MomoException::class); - $momo->collection()->requestToPay($request); + $collection->requestToPay($request); } public function testCheckTransactionStats() { - $paymentId = Utilities::guidv4(); + $paymentId = Uuid::v4(); $data = [ "financialTransactionId" => "476321816", "externalId" => "ORDER-10", @@ -154,12 +179,45 @@ function ($method, $url) use ($paymentId, $data): MockResponse { ]; MomoApi::useClient($this->provideClient($expectedRequests)); - $momo = MomoApi::create(MomoApi::ENVIRONMENT_SANDBOX); - $momo->setupCollection(Config::collection('testSubKey', "apiUser", "apiKey", "aCalllback")); - $transaction = $momo->collection()->checkRequestStatus($paymentId); + $collection = MomoApi::collection([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => 'aCalllback' + ]); + $transaction = $collection->getPaymentStatus($paymentId); $this->assertEquals($data['status'], $transaction->getStatus()); $this->assertEquals($data['payer']['partyId'], $transaction->getPayer()); $this->assertTrue($transaction->isSuccessful()); } + + public function testQuickPay() + { + $expectedRequests = [ + $this->provideTokenResponse(), + function ($method, $url, $options): MockResponse { + $this->assertSame('POST', $method); + $this->assertSame($this->baseUrl() . '/collection/v1_0/requesttopay', $url); + $body = json_decode($options['body'], true); + $this->assertEquals('1000', $body['amount']); + $this->assertEquals('242068511358', $body['payer']['partyId']); + $this->assertEquals('ORDER-123', $body['externalId']); + return new MockResponse('{}', ['http_code' => 202]); + }, + ]; + + MomoApi::useClient($this->provideClient($expectedRequests)); + $collection = MomoApi::collection([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + ]); + + $paymentId = $collection->quickPay('1000', '242068511358', 'ORDER-123'); + + $this->assertValidGuidV4($paymentId); + } } \ No newline at end of file diff --git a/tests/Unit/Products/DisbursementApiTest.php b/tests/Unit/Products/DisbursementApiTest.php new file mode 100644 index 0000000..3319246 --- /dev/null +++ b/tests/Unit/Products/DisbursementApiTest.php @@ -0,0 +1,189 @@ +assertSame($this->baseUrl() . '/disbursement/token/', $url); + return new MockResponse(json_encode([ + 'access_token' => 'testToken', + 'expires_in' => 3600, + 'token_type' => 'Bearer' + ]), ['http_code' => 200]); + }; + } + + public function testTransfer() + { + $expectedRequests = [ + $this->provideTokenResponse(), + function ($method, $url, $options): MockResponse { + $this->assertSame('POST', $method); + $this->assertSame($this->baseUrl() . '/disbursement/v1_0/transfer', $url); + $body = json_decode($options['body'], true); + $this->assertArrayHasKey('payee', $body); + $this->assertEquals('242068511358', $body['payee']['partyId']); + return new MockResponse('{}', ['http_code' => 202]); + }, + ]; + + MomoApi::useClient($this->provideClient($expectedRequests)); + $disbursement = MomoApi::disbursement([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => 'https://example.com/callback' + ]); + + $request = TransferRequest::make('1000', '242068511358', 'TRANSFER-001'); + $transferId = $disbursement->transfer($request); + + $this->assertValidGuidV4($transferId); + } + + public function testGetTransferStatus() + { + $transferId = '07a461a4-e721-462b-81c6-b9aa2f8abf06'; + $data = [ + "financialTransactionId" => "123456789", + "externalId" => "TRANSFER-001", + "amount" => "1000", + "currency" => "XAF", + "payee" => [ + "partyIdType" => "MSISDN", + "partyId" => "242068511358" + ], + "payerMessage" => "Transfer message", + "payeeNote" => "Transfer note", + "status" => "SUCCESSFUL" + ]; + + $expectedRequests = [ + $this->provideTokenResponse(), + function ($method, $url) use ($transferId, $data): MockResponse { + $this->assertSame('GET', $method); + $this->assertSame($this->baseUrl() . "/disbursement/v1_0/transfer/$transferId", $url); + return new MockResponse(json_encode($data), ['http_code' => 200]); + }, + ]; + + MomoApi::useClient($this->provideClient($expectedRequests)); + $disbursement = MomoApi::disbursement([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => '' + ]); + + $transaction = $disbursement->getTransferStatus($transferId); + + $this->assertEquals($data['status'], $transaction->getStatus()); + $this->assertEquals($data['payee']['partyId'], $transaction->getPayee()); + $this->assertTrue($transaction->isSuccessful()); + } + + public function testRefund() + { + $expectedRequests = [ + $this->provideTokenResponse(), + function ($method, $url, $options): MockResponse { + $this->assertSame('POST', $method); + $this->assertSame($this->baseUrl() . '/disbursement/v1_0/refund', $url); + $body = json_decode($options['body'], true); + $this->assertArrayHasKey('referenceIdToRefund', $body); + $this->assertEquals('07a461a4-e721-462b-81c6-b9aa2f8abf06', $body['referenceIdToRefund']); + return new MockResponse('{}', ['http_code' => 202]); + }, + ]; + + MomoApi::useClient($this->provideClient($expectedRequests)); + $disbursement = MomoApi::disbursement([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + ]); + + $request = RefundRequest::make('500', '07a461a4-e721-462b-81c6-b9aa2f8abf06', 'REFUND-001'); + $refundId = $disbursement->refund($request); + + $this->assertValidGuidV4($refundId); + } + + public function testGetRefundStatus() + { + $refundId = '07a461a4-e721-462b-81c6-b9aa2f8abf06'; + $data = [ + "financialTransactionId" => "987654321", + "externalId" => "REFUND-001", + "amount" => "500", + "currency" => "XAF", + "payer" => [ + "partyIdType" => "MSISDN", + "partyId" => "242068511358" + ], + "payerMessage" => "", + "payeeNote" => "", + "status" => "SUCCESSFUL" + ]; + + $expectedRequests = [ + $this->provideTokenResponse(), + function ($method, $url) use ($refundId, $data): MockResponse { + $this->assertSame('GET', $method); + $this->assertSame($this->baseUrl() . "/disbursement/v1_0/refund/$refundId", $url); + return new MockResponse(json_encode($data), ['http_code' => 200]); + }, + ]; + + MomoApi::useClient($this->provideClient($expectedRequests)); + $disbursement = MomoApi::disbursement([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + ]); + + $transaction = $disbursement->getRefundStatus($refundId); + + $this->assertEquals($data['status'], $transaction->getStatus()); + $this->assertTrue($transaction->isSuccessful()); + } + + public function testCallbackUrlHeader() + { + $callbackUrl = 'https://example.com/webhook'; + + $expectedRequests = [ + $this->provideTokenResponse(), + function ($method, $url, $options) use ($callbackUrl): MockResponse { + $this->assertContains("X-Callback-Url: $callbackUrl", $options['headers']); + return new MockResponse('{}', ['http_code' => 202]); + }, + ]; + + MomoApi::useClient($this->provideClient($expectedRequests)); + $disbursement = MomoApi::disbursement([ + 'environment' => 'sandbox', + 'subscription_key' => 'testSubKey', + 'api_user' => 'apiUser', + 'api_key' => 'apiKey', + 'callback_url' => $callbackUrl + ]); + + $request = TransferRequest::make('1000', '242068511358', 'TRANSFER-001'); + $disbursement->transfer($request); + } +} diff --git a/tests/Products/SandboxApiTest.php b/tests/Unit/Products/SandboxApiTest.php similarity index 93% rename from tests/Products/SandboxApiTest.php rename to tests/Unit/Products/SandboxApiTest.php index 281b405..c317e94 100644 --- a/tests/Products/SandboxApiTest.php +++ b/tests/Unit/Products/SandboxApiTest.php @@ -1,13 +1,13 @@ 'https://my-domain.com/callback', 'targetEnvironment' => 'sandbox', ]; - $uuid = Utilities::guidv4(); + $uuid = Uuid::v4(); $expectedRequests = [ function ($method, $url) use ($user, $uuid): MockResponse { @@ -115,13 +115,13 @@ function ($method, $url) use ($user, $uuid): MockResponse { public function test404IfApiUserNotFound() { - $this->expectException(RessourceNotFoundException::class); + $this->expectException(ResourceNotFoundException::class); $user = [ 'providerCallbackHost' => 'https://my-domain.com/callback', 'targetEnvironment' => 'sandbox', ]; - $uuid = Utilities::guidv4(); + $uuid = Uuid::v4(); $expectedRequests = [ function () use ($user, $uuid): MockResponse { @@ -136,7 +136,7 @@ function () use ($user, $uuid): MockResponse { public function testCreateApiKey() { - $apiUser = Utilities::guidv4(); + $apiUser = Uuid::v4(); $expectedRequests = [ function ($method, $url) use ($apiUser): MockResponse { @@ -155,7 +155,7 @@ function ($method, $url) use ($apiUser): MockResponse { public function testThrowIfUnableToCreateApiKey() { - $apiUser = Utilities::guidv4(); + $apiUser = Uuid::v4(); $expectedRequests = [ function () use ($apiUser): MockResponse { diff --git a/tests/UtilitiesTest.php b/tests/UuidTest.php similarity index 57% rename from tests/UtilitiesTest.php rename to tests/UuidTest.php index 2fc6b64..53e0376 100644 --- a/tests/UtilitiesTest.php +++ b/tests/UuidTest.php @@ -3,13 +3,13 @@ namespace Tests; -use Lepresk\MomoApi\Utilities; +use Lepresk\MomoApi\Support\Uuid; -class UtilitiesTest extends TestCase +class UuidTest extends TestCase { public function testValidGuidv4() { - $guidv4 = Utilities::guidv4(); + $guidv4 = Uuid::v4(); $this->assertValidGuidV4($guidv4); } } \ No newline at end of file