From cedce441ec9f0d8f4ed680facf53cac42ddf4d28 Mon Sep 17 00:00:00 2001 From: lepres Date: Sun, 26 Oct 2025 18:38:12 +0100 Subject: [PATCH 1/6] feat: complete disbursement API and add fluent configuration - Fix endpoint paths and callback URL handling - Add transfer and refund operations - Implement fluent API with improved DX - Add ErrorReason class for structured errors - Increase test coverage to 39 tests - Add PHPStan level 5 validation --- .gitignore | 5 +- CLAUDE.md | 187 ++++++++++ README.md | 341 +++++++++++------- composer.json | 13 +- composer.lock | 109 +++++- phpstan.neon | 7 + ...eExeption.php => BadResourceException.php} | 2 +- src/Exceptions/ExceptionFactory.php | 2 +- ...tion.php => ResourceNotFoundException.php} | 2 +- src/Models/ErrorReason.php | 74 ++++ src/Models/PaymentRequest.php | 48 ++- src/Models/RefundRequest.php | 144 ++++++++ src/Models/Transaction.php | 28 +- src/Models/TransferRequest.php | 148 ++++++++ src/MomoApi.php | 78 ++-- src/Products/CollectionApi.php | 65 +++- src/Products/DisbursementApi.php | 242 ++++++++++++- tests/Models/ErrorReasonTest.php | 76 ++++ tests/Models/RefundRequestTest.php | 70 ++++ tests/Models/TransferRequestTest.php | 65 ++++ tests/MomoApiTest.php | 7 +- tests/Products/CollectionApiTest.php | 94 ++++- tests/Products/DisbursementApiTest.php | 189 ++++++++++ tests/Products/SandboxApiTest.php | 4 +- 24 files changed, 1766 insertions(+), 234 deletions(-) create mode 100644 CLAUDE.md create mode 100644 phpstan.neon rename src/Exceptions/{BadRessourceExeption.php => BadResourceException.php} (81%) rename src/Exceptions/{RessourceNotFoundException.php => ResourceNotFoundException.php} (82%) create mode 100644 src/Models/ErrorReason.php create mode 100644 src/Models/RefundRequest.php create mode 100644 src/Models/TransferRequest.php create mode 100644 tests/Models/ErrorReasonTest.php create mode 100644 tests/Models/RefundRequestTest.php create mode 100644 tests/Models/TransferRequestTest.php create mode 100644 tests/Products/DisbursementApiTest.php diff --git a/.gitignore b/.gitignore index 9ffb4d5..3b7f151 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ vendor .fleet .idea .vscode -index.php \ No newline at end of file +index.php +phpstan-baseline.neon +MtnPaymentMethod.php +disbursement.yaml \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bec69d0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,187 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Professional PHP library providing a modern, fluent wrapper for MTN Mobile Money (MoMo) API. Supports **Collection** (receive payments from customers) and **Disbursement** (send money to beneficiaries) across multiple African countries. + +## Development Commands + +### Testing +```bash +composer test +vendor/bin/phpunit +vendor/bin/phpunit --coverage-html coverage +``` + +### Dependencies +```bash +composer install +composer update +``` + +## Architecture + +### Core Components + +**MomoApi (src/MomoApi.php)** - Main entry point with fluent API +- Fluent factories: `MomoApi::collection([...config])`, `MomoApi::disbursement([...config])` +- Legacy factory: `MomoApi::create($environment)` (backward compatibility) +- Manages Symfony HttpClient instance (singleton pattern) +- Environment-aware URL routing + +**Config (src/Config.php)** - Immutable configuration +- Factory methods: `Config::sandbox()`, `Config::collection()`, `Config::disbursement()` +- Properties: subscriptionKey, apiUser, apiKey, callbackUri + +**ApiProduct (src/ApiProduct.php)** - Abstract base for product APIs +- Base class for SandboxApi, CollectionApi, DisbursementApi +- Provides HttpClient, environment, config access + +### Product APIs (src/Products/) + +**CollectionApi** - Receive payments from customers +- `requestToPay(PaymentRequest)` - Request payment (uses "payer") +- `quickPay(amount, phone, ref)` - Convenience helper +- `getPaymentStatus(paymentId)` - Check status (accepts 200/202) +- `getBalance()` - Account balance +- Sends X-Callback-Url header when configured + +**DisbursementApi** - Send money to beneficiaries +- `transfer(TransferRequest)` - Send money (uses "payee") +- `getTransferStatus(transferId)` - Check transfer +- `deposit(PaymentRequest)` - Deposit operation +- `getDepositStatus(depositId)` - Check deposit +- `refund(RefundRequest)` - Process refund +- `getRefundStatus(refundId)` - Check refund +- `getBalance()` - Account balance +- All operations send X-Callback-Url when configured + +**SandboxApi** - Sandbox provisioning +- `createApiUser(uuid, callback)` - Create test user +- `getApiUser(uuid)` - Get user info +- `createApiKey(uuid)` - Generate API key + +### Models (src/Models/) + +**PaymentRequest** - Collection payment model +- Uses "payer" field (customer who pays) +- Properties: amount (string), currency, externalId, payer, payerMessage, payeeNote +- Helper: `PaymentRequest::make(amount, payer, externalId, currency='XAF')` + +**TransferRequest** - Disbursement transfer model +- Uses "payee" field (beneficiary who receives) +- Properties: amount (string), currency, externalId, payee, payerMessage, payeeNote +- Helper: `TransferRequest::make(amount, payee, externalId, currency='XAF')` + +**RefundRequest** - Refund model +- Additional: referenceIdToRefund (UUID of original transaction) +- Helper: `RefundRequest::make(amount, refId, externalId, currency='XAF')` + +**Transaction** - Response model +- Parses both Collection (payer) and Disbursement (payee) responses +- Status helpers: `isSuccessful()`, `isPending()`, `isFailed()` +- Getters: `getPayer()`, `getPayee()` (alias), `getReason()` (returns ErrorReason) + +**ErrorReason** - Structured error information +- Constants for all error codes (PAYEE_NOT_FOUND, NOT_ENOUGH_FUNDS, etc.) +- Helpers: `isNotEnoughFunds()`, `isPayerLimitReached()`, etc. +- String representation: `[CODE] message` + +**AccountBalance** - Balance response +- Properties: availableBalance, currency + +### Exception Handling (src/Exceptions/) + +**ExceptionFactory** - Maps HTTP codes to exceptions +- 400 → BadRequestExeption +- 401 → InvalidSubscriptionKeyException +- 404 → ResourceNotFoundException +- 409 → ConflictException +- 500 → InternalServerErrorException + +All exceptions extend `MomoException` + +## Key Patterns + +### Fluent Configuration +```php +$collection = MomoApi::collection([ + 'environment' => 'sandbox', + 'subscription_key' => '...', + 'api_user' => '...', + 'api_key' => '...', + 'callback_url' => 'https://...' +]); +``` + +### Collection vs Disbursement Semantics +- **Collection**: Customer → Merchant (uses "payer") +- **Disbursement**: Business → Beneficiary (uses "payee") + +### Callback Flow +1. Configure callback_url in config +2. Library sends X-Callback-Url header automatically +3. MTN sends GET request to callback URL on status change +4. Parse with `Transaction::parse($_GET)` + +### Status Code Handling +- Success: 200 or 202 (both accepted) +- 202 = Accepted/Pending (valid success response) + +### Error Handling +```php +try { + $payment = $collection->quickPay(...); +} catch (ResourceNotFoundException $e) { + // 404 +} catch (InternalServerErrorException $e) { + // 500 +} + +// Or check transaction reason +if ($transaction->isFailed()) { + $reason = $transaction->getReason(); + if ($reason->isNotEnoughFunds()) { ... } +} +``` + +## API Endpoints + +### Collection +- POST `/collection/v1_0/requesttopay` - Request payment +- GET `/collection/v1_0/requesttopay/{id}` - Get status +- GET `/collection/v1_0/account/balance` - Get balance +- POST `/collection/token/` - Get OAuth token (auto-handled) + +### Disbursement +- POST `/disbursement/v1_0/transfer` - Transfer money +- GET `/disbursement/v1_0/transfer/{id}` - Get transfer status +- POST `/disbursement/v1_0/deposit` - Deposit funds +- GET `/disbursement/v1_0/deposit/{id}` - Get deposit status +- POST `/disbursement/v1_0/refund` - Process refund +- GET `/disbursement/v1_0/refund/{id}` - Get refund status +- GET `/disbursement/v1_0/account/balance` - Get balance +- POST `/disbursement/token/` - Get OAuth token (auto-handled) + +### Sandbox +- POST `/v1_0/apiuser` - Create API user +- GET `/v1_0/apiuser/{uuid}` - Get API user +- POST `/v1_0/apiuser/{uuid}/apikey` - Create API key + +## Important Notes + +- **Amount Type**: Always string (matches API spec) +- **Phone Format**: International format without + (e.g., "242068511358") +- **UUIDs**: Use `Utilities::guidv4()` for reference IDs +- **Tokens**: Auto-managed, no manual handling needed +- **Callbacks**: Always verify transaction via API, don't trust callback alone +- **Environment**: Use constants from MomoApi class + +## Testing + +- Mock responses using `MockResponse` +- Override client: `MomoApi::useClient($mockClient)` +- Test helpers: `tests/TestCase.php` +- Example: `tests/Products/SandboxApiTest.php` diff --git a/README.md b/README.md index b1d9be1..3c1be24 100644 --- a/README.md +++ b/README.md @@ -3,186 +3,285 @@ [![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); - -echo $transaction->getStatus(); // Pour obtenir le statut de la transaction +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"; + } +} ``` -#### Gérer le hook du callback +## 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 -```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/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/Models/ErrorReason.php b/src/Models/ErrorReason.php new file mode 100644 index 0000000..c6de4ae --- /dev/null +++ b/src/Models/ErrorReason.php @@ -0,0 +1,74 @@ +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..7e184de 100644 --- a/src/MomoApi.php +++ b/src/MomoApi.php @@ -33,10 +33,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 +63,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 +139,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..0d5d140 100644 --- a/src/Products/CollectionApi.php +++ b/src/Products/CollectionApi.php @@ -53,16 +53,23 @@ public function requestToPay(PaymentRequest $paymentRequest): string $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 +130,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 +144,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 +155,8 @@ public function checkRequestStatus(string $paymentId): Transaction ] ]); - if ($response->getStatusCode() === 200) { + $statusCode = $response->getStatusCode(); + if ($statusCode === 200 || $statusCode === 202) { return Transaction::parse($response->toArray()); } @@ -166,7 +174,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 +191,37 @@ public function getAccountBalance(): AccountBalance throw ExceptionFactory::create($response); } + + /** + * Quick payment helper with sensible defaults + * + * @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..c5b03e3 100644 --- a/src/Products/DisbursementApi.php +++ b/src/Products/DisbursementApi.php @@ -8,6 +8,8 @@ use Lepresk\MomoApi\Exceptions\MomoException; use Lepresk\MomoApi\Models\AccountBalance; 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 Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; @@ -66,7 +68,7 @@ public function getAccessToken(): ApiToken * @throws ServerExceptionInterface * @throws TransportExceptionInterface */ - public function getAccountBalance(): AccountBalance + public function getBalance(): AccountBalance { $token = $this->getAccessToken(); @@ -86,7 +88,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 +101,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 +112,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(); - $response = $this->client->request('POST', '/disbursement/v1_0/account/deposit', [ + $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/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 +159,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 +184,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 = Utilities::guidv4(); + + $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 = Utilities::guidv4(); + + $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/tests/Models/ErrorReasonTest.php b/tests/Models/ErrorReasonTest.php new file mode 100644 index 0000000..f5254b3 --- /dev/null +++ b/tests/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/Models/RefundRequestTest.php b/tests/Models/RefundRequestTest.php new file mode 100644 index 0000000..baa7b2f --- /dev/null +++ b/tests/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/Models/TransferRequestTest.php b/tests/Models/TransferRequestTest.php new file mode 100644 index 0000000..64eb221 --- /dev/null +++ b/tests/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/MomoApiTest.php b/tests/MomoApiTest.php index a250951..4de09d2 100644 --- a/tests/MomoApiTest.php +++ b/tests/MomoApiTest.php @@ -19,11 +19,12 @@ 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() diff --git a/tests/Products/CollectionApiTest.php b/tests/Products/CollectionApiTest.php index 39cf918..9fca18d 100644 --- a/tests/Products/CollectionApiTest.php +++ b/tests/Products/CollectionApiTest.php @@ -25,9 +25,14 @@ function ($method, $url, $options) use ($subscriptionKey): MockResponse { ]; MomoApi::useClient($this->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,13 +138,18 @@ 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() @@ -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/Products/DisbursementApiTest.php b/tests/Products/DisbursementApiTest.php new file mode 100644 index 0000000..f14b601 --- /dev/null +++ b/tests/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/Products/SandboxApiTest.php index 281b405..c6aa9d6 100644 --- a/tests/Products/SandboxApiTest.php +++ b/tests/Products/SandboxApiTest.php @@ -5,7 +5,7 @@ use Lepresk\MomoApi\Exceptions\BadRequestExeption; use Lepresk\MomoApi\Exceptions\ConflictException; use Lepresk\MomoApi\Exceptions\MomoException; -use Lepresk\MomoApi\Exceptions\RessourceNotFoundException; +use Lepresk\MomoApi\Exceptions\ResourceNotFoundException; use Lepresk\MomoApi\MomoApi; use Lepresk\MomoApi\Utilities; use Symfony\Component\HttpClient\Response\MockResponse; @@ -115,7 +115,7 @@ 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', From c01bdf402d2d9d6f268194ad41ab1577b0eeebda Mon Sep 17 00:00:00 2001 From: lepres Date: Sun, 26 Oct 2025 21:37:31 +0100 Subject: [PATCH 2/6] test: add fixture tests with real MTN API responses - Reorganize tests into Unit/ and FixtureTests/ structure - Add 20 JSON fixtures from official API specification - Create fixture tests for Collection, Disbursement, and Sandbox - Update phpunit.xml with separate testsuites - Document testing approach in README --- .gitignore | 3 +- CLAUDE.md | 187 ------------------ README.md | 15 ++ phpunit.xml | 9 +- tests/FixtureTests/CollectionFixtureTest.php | 114 +++++++++++ .../FixtureTests/DisbursementFixtureTest.php | 151 ++++++++++++++ tests/FixtureTests/SandboxFixtureTest.php | 19 ++ tests/Fixtures/Collection/balance.json | 4 + .../Collection/error_resource_not_found.json | 4 + .../payment_failed_not_enough_funds.json | 16 ++ .../payment_failed_payer_limit.json | 16 ++ .../Fixtures/Collection/payment_pending.json | 13 ++ .../Collection/payment_successful.json | 13 ++ tests/Fixtures/Collection/token_success.json | 5 + tests/Fixtures/Disbursement/balance.json | 4 + .../Disbursement/deposit_successful.json | 13 ++ .../error_resource_not_found.json | 4 + .../Fixtures/Disbursement/refund_pending.json | 12 ++ .../Disbursement/refund_successful.json | 11 ++ .../Fixtures/Disbursement/token_success.json | 5 + .../transfer_failed_limit_reached.json | 14 ++ .../transfer_failed_not_enough_funds.json | 14 ++ .../Disbursement/transfer_pending.json | 13 ++ .../Disbursement/transfer_successful.json | 11 ++ tests/Fixtures/Sandbox/apiuser_key.json | 3 + tests/MomoApiTest.php | 2 +- tests/{ => Unit}/Models/ErrorReasonTest.php | 2 +- tests/{ => Unit}/Models/RefundRequestTest.php | 2 +- .../{ => Unit}/Models/TransferRequestTest.php | 2 +- .../{ => Unit}/Products/CollectionApiTest.php | 2 +- .../Products/DisbursementApiTest.php | 2 +- tests/{ => Unit}/Products/SandboxApiTest.php | 2 +- 32 files changed, 490 insertions(+), 197 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 tests/FixtureTests/CollectionFixtureTest.php create mode 100644 tests/FixtureTests/DisbursementFixtureTest.php create mode 100644 tests/FixtureTests/SandboxFixtureTest.php create mode 100644 tests/Fixtures/Collection/balance.json create mode 100644 tests/Fixtures/Collection/error_resource_not_found.json create mode 100644 tests/Fixtures/Collection/payment_failed_not_enough_funds.json create mode 100644 tests/Fixtures/Collection/payment_failed_payer_limit.json create mode 100644 tests/Fixtures/Collection/payment_pending.json create mode 100644 tests/Fixtures/Collection/payment_successful.json create mode 100644 tests/Fixtures/Collection/token_success.json create mode 100644 tests/Fixtures/Disbursement/balance.json create mode 100644 tests/Fixtures/Disbursement/deposit_successful.json create mode 100644 tests/Fixtures/Disbursement/error_resource_not_found.json create mode 100644 tests/Fixtures/Disbursement/refund_pending.json create mode 100644 tests/Fixtures/Disbursement/refund_successful.json create mode 100644 tests/Fixtures/Disbursement/token_success.json create mode 100644 tests/Fixtures/Disbursement/transfer_failed_limit_reached.json create mode 100644 tests/Fixtures/Disbursement/transfer_failed_not_enough_funds.json create mode 100644 tests/Fixtures/Disbursement/transfer_pending.json create mode 100644 tests/Fixtures/Disbursement/transfer_successful.json create mode 100644 tests/Fixtures/Sandbox/apiuser_key.json rename tests/{ => Unit}/Models/ErrorReasonTest.php (98%) rename tests/{ => Unit}/Models/RefundRequestTest.php (98%) rename tests/{ => Unit}/Models/TransferRequestTest.php (98%) rename tests/{ => Unit}/Products/CollectionApiTest.php (99%) rename tests/{ => Unit}/Products/DisbursementApiTest.php (99%) rename tests/{ => Unit}/Products/SandboxApiTest.php (99%) diff --git a/.gitignore b/.gitignore index 3b7f151..eb9e971 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ vendor index.php phpstan-baseline.neon MtnPaymentMethod.php -disbursement.yaml \ No newline at end of file +disbursement.yaml +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index bec69d0..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,187 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Professional PHP library providing a modern, fluent wrapper for MTN Mobile Money (MoMo) API. Supports **Collection** (receive payments from customers) and **Disbursement** (send money to beneficiaries) across multiple African countries. - -## Development Commands - -### Testing -```bash -composer test -vendor/bin/phpunit -vendor/bin/phpunit --coverage-html coverage -``` - -### Dependencies -```bash -composer install -composer update -``` - -## Architecture - -### Core Components - -**MomoApi (src/MomoApi.php)** - Main entry point with fluent API -- Fluent factories: `MomoApi::collection([...config])`, `MomoApi::disbursement([...config])` -- Legacy factory: `MomoApi::create($environment)` (backward compatibility) -- Manages Symfony HttpClient instance (singleton pattern) -- Environment-aware URL routing - -**Config (src/Config.php)** - Immutable configuration -- Factory methods: `Config::sandbox()`, `Config::collection()`, `Config::disbursement()` -- Properties: subscriptionKey, apiUser, apiKey, callbackUri - -**ApiProduct (src/ApiProduct.php)** - Abstract base for product APIs -- Base class for SandboxApi, CollectionApi, DisbursementApi -- Provides HttpClient, environment, config access - -### Product APIs (src/Products/) - -**CollectionApi** - Receive payments from customers -- `requestToPay(PaymentRequest)` - Request payment (uses "payer") -- `quickPay(amount, phone, ref)` - Convenience helper -- `getPaymentStatus(paymentId)` - Check status (accepts 200/202) -- `getBalance()` - Account balance -- Sends X-Callback-Url header when configured - -**DisbursementApi** - Send money to beneficiaries -- `transfer(TransferRequest)` - Send money (uses "payee") -- `getTransferStatus(transferId)` - Check transfer -- `deposit(PaymentRequest)` - Deposit operation -- `getDepositStatus(depositId)` - Check deposit -- `refund(RefundRequest)` - Process refund -- `getRefundStatus(refundId)` - Check refund -- `getBalance()` - Account balance -- All operations send X-Callback-Url when configured - -**SandboxApi** - Sandbox provisioning -- `createApiUser(uuid, callback)` - Create test user -- `getApiUser(uuid)` - Get user info -- `createApiKey(uuid)` - Generate API key - -### Models (src/Models/) - -**PaymentRequest** - Collection payment model -- Uses "payer" field (customer who pays) -- Properties: amount (string), currency, externalId, payer, payerMessage, payeeNote -- Helper: `PaymentRequest::make(amount, payer, externalId, currency='XAF')` - -**TransferRequest** - Disbursement transfer model -- Uses "payee" field (beneficiary who receives) -- Properties: amount (string), currency, externalId, payee, payerMessage, payeeNote -- Helper: `TransferRequest::make(amount, payee, externalId, currency='XAF')` - -**RefundRequest** - Refund model -- Additional: referenceIdToRefund (UUID of original transaction) -- Helper: `RefundRequest::make(amount, refId, externalId, currency='XAF')` - -**Transaction** - Response model -- Parses both Collection (payer) and Disbursement (payee) responses -- Status helpers: `isSuccessful()`, `isPending()`, `isFailed()` -- Getters: `getPayer()`, `getPayee()` (alias), `getReason()` (returns ErrorReason) - -**ErrorReason** - Structured error information -- Constants for all error codes (PAYEE_NOT_FOUND, NOT_ENOUGH_FUNDS, etc.) -- Helpers: `isNotEnoughFunds()`, `isPayerLimitReached()`, etc. -- String representation: `[CODE] message` - -**AccountBalance** - Balance response -- Properties: availableBalance, currency - -### Exception Handling (src/Exceptions/) - -**ExceptionFactory** - Maps HTTP codes to exceptions -- 400 → BadRequestExeption -- 401 → InvalidSubscriptionKeyException -- 404 → ResourceNotFoundException -- 409 → ConflictException -- 500 → InternalServerErrorException - -All exceptions extend `MomoException` - -## Key Patterns - -### Fluent Configuration -```php -$collection = MomoApi::collection([ - 'environment' => 'sandbox', - 'subscription_key' => '...', - 'api_user' => '...', - 'api_key' => '...', - 'callback_url' => 'https://...' -]); -``` - -### Collection vs Disbursement Semantics -- **Collection**: Customer → Merchant (uses "payer") -- **Disbursement**: Business → Beneficiary (uses "payee") - -### Callback Flow -1. Configure callback_url in config -2. Library sends X-Callback-Url header automatically -3. MTN sends GET request to callback URL on status change -4. Parse with `Transaction::parse($_GET)` - -### Status Code Handling -- Success: 200 or 202 (both accepted) -- 202 = Accepted/Pending (valid success response) - -### Error Handling -```php -try { - $payment = $collection->quickPay(...); -} catch (ResourceNotFoundException $e) { - // 404 -} catch (InternalServerErrorException $e) { - // 500 -} - -// Or check transaction reason -if ($transaction->isFailed()) { - $reason = $transaction->getReason(); - if ($reason->isNotEnoughFunds()) { ... } -} -``` - -## API Endpoints - -### Collection -- POST `/collection/v1_0/requesttopay` - Request payment -- GET `/collection/v1_0/requesttopay/{id}` - Get status -- GET `/collection/v1_0/account/balance` - Get balance -- POST `/collection/token/` - Get OAuth token (auto-handled) - -### Disbursement -- POST `/disbursement/v1_0/transfer` - Transfer money -- GET `/disbursement/v1_0/transfer/{id}` - Get transfer status -- POST `/disbursement/v1_0/deposit` - Deposit funds -- GET `/disbursement/v1_0/deposit/{id}` - Get deposit status -- POST `/disbursement/v1_0/refund` - Process refund -- GET `/disbursement/v1_0/refund/{id}` - Get refund status -- GET `/disbursement/v1_0/account/balance` - Get balance -- POST `/disbursement/token/` - Get OAuth token (auto-handled) - -### Sandbox -- POST `/v1_0/apiuser` - Create API user -- GET `/v1_0/apiuser/{uuid}` - Get API user -- POST `/v1_0/apiuser/{uuid}/apikey` - Create API key - -## Important Notes - -- **Amount Type**: Always string (matches API spec) -- **Phone Format**: International format without + (e.g., "242068511358") -- **UUIDs**: Use `Utilities::guidv4()` for reference IDs -- **Tokens**: Auto-managed, no manual handling needed -- **Callbacks**: Always verify transaction via API, don't trust callback alone -- **Environment**: Use constants from MomoApi class - -## Testing - -- Mock responses using `MockResponse` -- Override client: `MomoApi::useClient($mockClient)` -- Test helpers: `tests/TestCase.php` -- Example: `tests/Products/SandboxApiTest.php` diff --git a/README.md b/README.md index 3c1be24..419b56b 100644 --- a/README.md +++ b/README.md @@ -261,8 +261,23 @@ if ($transaction->isFailed()) { ## 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 +``` + +**Fixture Tests** - Validate parsing of real MTN API responses: +```bash +vendor/bin/phpunit --testsuite Fixtures +``` + +Run PHPStan analysis: +```bash +composer phpstan ``` ## Production Notes diff --git a/phpunit.xml b/phpunit.xml index b81b98b..e10b070 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,8 +5,13 @@ colors="true" > - - ./tests + + ./tests/Unit + ./tests/MomoApiTest.php + ./tests/UtilitiesTest.php + + + ./tests/FixtureTests diff --git a/tests/FixtureTests/CollectionFixtureTest.php b/tests/FixtureTests/CollectionFixtureTest.php new file mode 100644 index 0000000..6571f5d --- /dev/null +++ b/tests/FixtureTests/CollectionFixtureTest.php @@ -0,0 +1,114 @@ +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..30352e2 --- /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 4de09d2..4870a6b 100644 --- a/tests/MomoApiTest.php +++ b/tests/MomoApiTest.php @@ -31,7 +31,7 @@ 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/Models/ErrorReasonTest.php b/tests/Unit/Models/ErrorReasonTest.php similarity index 98% rename from tests/Models/ErrorReasonTest.php rename to tests/Unit/Models/ErrorReasonTest.php index f5254b3..6a8d105 100644 --- a/tests/Models/ErrorReasonTest.php +++ b/tests/Unit/Models/ErrorReasonTest.php @@ -1,7 +1,7 @@ Date: Sun, 26 Oct 2025 22:10:21 +0100 Subject: [PATCH 3/6] refactor: modernize package structure with Laravel conventions - Add Abstracts/ directory with AbstractApiProduct base class - Add Concerns/ directory with InteractsWithHttp trait - Add Support/ directory with Uuid helper (renamed from Utilities) - Move Config and ApiToken to Models/ namespace - Update all Products to extend AbstractApiProduct - Rename Utilities::guidv4() to Uuid::v4() across codebase - Update all imports and tests --- phpunit.xml | 2 +- .../AbstractApiProduct.php} | 13 ++++------ src/Concerns/InteractsWithHttp.php | 26 +++++++++++++++++++ src/{ => Models}/ApiToken.php | 2 +- src/{ => Models}/Config.php | 2 +- src/MomoApi.php | 1 + src/Products/CollectionApi.php | 12 +++++---- src/Products/DisbursementApi.php | 16 +++++++----- src/Products/SandboxApi.php | 4 +-- src/{Utilities.php => Support/Uuid.php} | 8 +++--- tests/FixtureTests/CollectionFixtureTest.php | 2 +- .../FixtureTests/DisbursementFixtureTest.php | 2 +- tests/Unit/Products/CollectionApiTest.php | 6 ++--- tests/Unit/Products/SandboxApiTest.php | 14 +++++----- tests/{UtilitiesTest.php => UuidTest.php} | 6 ++--- 15 files changed, 72 insertions(+), 44 deletions(-) rename src/{ApiProduct.php => Abstracts/AbstractApiProduct.php} (74%) create mode 100644 src/Concerns/InteractsWithHttp.php rename src/{ => Models}/ApiToken.php (96%) rename src/{ => Models}/Config.php (98%) rename src/{Utilities.php => Support/Uuid.php} (83%) rename tests/{UtilitiesTest.php => UuidTest.php} (57%) diff --git a/phpunit.xml b/phpunit.xml index e10b070..ce83ef4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -8,7 +8,7 @@ ./tests/Unit ./tests/MomoApiTest.php - ./tests/UtilitiesTest.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/Concerns/InteractsWithHttp.php b/src/Concerns/InteractsWithHttp.php new file mode 100644 index 0000000..3ec1d7c --- /dev/null +++ b/src/Concerns/InteractsWithHttp.php @@ -0,0 +1,26 @@ + $this->getSubscriptionKey(), + 'X-Target-Environment' => $this->environment, + 'Authorization' => 'Bearer ' . $token->getAccessToken(), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + if (!empty($this->config->getCallbackUri())) { + $headers['X-Callback-Url'] = $this->config->getCallbackUri(); + } + + return array_merge($headers, $additional); + } +} 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 @@ getAccessToken(); diff --git a/src/Products/DisbursementApi.php b/src/Products/DisbursementApi.php index c5b03e3..9d9a257 100644 --- a/src/Products/DisbursementApi.php +++ b/src/Products/DisbursementApi.php @@ -2,24 +2,26 @@ namespace Lepresk\MomoApi\Products; -use Lepresk\MomoApi\ApiProduct; -use Lepresk\MomoApi\ApiToken; +use Lepresk\MomoApi\Abstracts\AbstractApiProduct; +use Lepresk\MomoApi\Concerns\InteractsWithHttp; 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 { + use InteractsWithHttp; /** * Create an access token which can then be used to authorize and authenticate @@ -116,7 +118,7 @@ public function deposit(PaymentRequest $paymentRequest): string { $token = $this->getAccessToken(); - $xReferenceId = Utilities::guidv4(); + $xReferenceId = Uuid::v4(); $headers = [ 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), @@ -218,7 +220,7 @@ public function transfer(TransferRequest $transferRequest): string { $token = $this->getAccessToken(); - $xReferenceId = Utilities::guidv4(); + $xReferenceId = Uuid::v4(); $headers = [ 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), @@ -319,7 +321,7 @@ public function refund(RefundRequest $refundRequest): string { $token = $this->getAccessToken(); - $xReferenceId = Utilities::guidv4(); + $xReferenceId = Uuid::v4(); $headers = [ 'Ocp-Apim-Subscription-Key' => $this->getSubscriptionKey(), 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 @@ "476321816", "externalId" => "ORDER-10", diff --git a/tests/Unit/Products/SandboxApiTest.php b/tests/Unit/Products/SandboxApiTest.php index 5c0fc85..c317e94 100644 --- a/tests/Unit/Products/SandboxApiTest.php +++ b/tests/Unit/Products/SandboxApiTest.php @@ -7,7 +7,7 @@ use Lepresk\MomoApi\Exceptions\MomoException; use Lepresk\MomoApi\Exceptions\ResourceNotFoundException; use Lepresk\MomoApi\MomoApi; -use Lepresk\MomoApi\Utilities; +use Lepresk\MomoApi\Support\Uuid; use Symfony\Component\HttpClient\Response\MockResponse; use Tests\TestCase; @@ -33,7 +33,7 @@ function ($method, $url, $options) use ($subscriptionKey): MockResponse { public function testCreateApiUser() { $callbackHost = 'https://my-domain.com/callback'; - $uuid = Utilities::guidv4(); + $uuid = Uuid::v4(); $expectedRequests = [ function ($method, $url, $options) use ($callbackHost, $uuid): MockResponse { @@ -57,7 +57,7 @@ function ($method, $url, $options) use ($callbackHost, $uuid): MockResponse { public function testThrowConflictIfApiUserExists() { $callbackHost = 'https://my-domain.com/callback'; - $uuid = Utilities::guidv4(); + $uuid = Uuid::v4(); $expectedRequests = [ function () use ($callbackHost, $uuid): MockResponse { @@ -95,7 +95,7 @@ public function testGetApiUser() 'providerCallbackHost' => 'https://my-domain.com/callback', 'targetEnvironment' => 'sandbox', ]; - $uuid = Utilities::guidv4(); + $uuid = Uuid::v4(); $expectedRequests = [ function ($method, $url) use ($user, $uuid): MockResponse { @@ -121,7 +121,7 @@ public function test404IfApiUserNotFound() '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 From f724482b9df15bb4cbbed7ff8f5c361fc0023b0c Mon Sep 17 00:00:00 2001 From: lepres Date: Sun, 26 Oct 2025 22:17:32 +0100 Subject: [PATCH 4/6] docs: add sample usage for getBalance and quickPay methods --- src/Products/CollectionApi.php | 17 +++++++++++++++++ src/Products/DisbursementApi.php | 8 ++++++++ 2 files changed, 25 insertions(+) diff --git a/src/Products/CollectionApi.php b/src/Products/CollectionApi.php index 3de26c0..ff25c88 100644 --- a/src/Products/CollectionApi.php +++ b/src/Products/CollectionApi.php @@ -168,6 +168,14 @@ public function getPaymentStatus(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 @@ -197,6 +205,15 @@ public function getBalance(): AccountBalance /** * 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 diff --git a/src/Products/DisbursementApi.php b/src/Products/DisbursementApi.php index 9d9a257..7ca641d 100644 --- a/src/Products/DisbursementApi.php +++ b/src/Products/DisbursementApi.php @@ -62,6 +62,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 From b24d6fdea067743ef742b586668001385221bb81 Mon Sep 17 00:00:00 2001 From: lepres Date: Sun, 26 Oct 2025 22:23:09 +0100 Subject: [PATCH 5/6] refactor: remove unused InteractsWithHttp trait - Remove src/Concerns/InteractsWithHttp.php (dead code) - Remove unused imports and trait usage from Products - Delete empty Concerns/ directory - Keep only used code following YAGNI principle --- src/Concerns/InteractsWithHttp.php | 26 -------------------------- src/Products/CollectionApi.php | 3 --- src/Products/DisbursementApi.php | 3 --- 3 files changed, 32 deletions(-) delete mode 100644 src/Concerns/InteractsWithHttp.php diff --git a/src/Concerns/InteractsWithHttp.php b/src/Concerns/InteractsWithHttp.php deleted file mode 100644 index 3ec1d7c..0000000 --- a/src/Concerns/InteractsWithHttp.php +++ /dev/null @@ -1,26 +0,0 @@ - $this->getSubscriptionKey(), - 'X-Target-Environment' => $this->environment, - 'Authorization' => 'Bearer ' . $token->getAccessToken(), - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ]; - - if (!empty($this->config->getCallbackUri())) { - $headers['X-Callback-Url'] = $this->config->getCallbackUri(); - } - - return array_merge($headers, $additional); - } -} diff --git a/src/Products/CollectionApi.php b/src/Products/CollectionApi.php index ff25c88..6d1daff 100644 --- a/src/Products/CollectionApi.php +++ b/src/Products/CollectionApi.php @@ -4,7 +4,6 @@ namespace Lepresk\MomoApi\Products; use Lepresk\MomoApi\Abstracts\AbstractApiProduct; -use Lepresk\MomoApi\Concerns\InteractsWithHttp; use Lepresk\MomoApi\Exceptions\ExceptionFactory; use Lepresk\MomoApi\Exceptions\MomoException; use Lepresk\MomoApi\Models\AccountBalance; @@ -20,8 +19,6 @@ class CollectionApi extends AbstractApiProduct { - use InteractsWithHttp; - /** * 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. diff --git a/src/Products/DisbursementApi.php b/src/Products/DisbursementApi.php index 7ca641d..13cfa09 100644 --- a/src/Products/DisbursementApi.php +++ b/src/Products/DisbursementApi.php @@ -3,7 +3,6 @@ namespace Lepresk\MomoApi\Products; use Lepresk\MomoApi\Abstracts\AbstractApiProduct; -use Lepresk\MomoApi\Concerns\InteractsWithHttp; use Lepresk\MomoApi\Exceptions\ExceptionFactory; use Lepresk\MomoApi\Exceptions\MomoException; use Lepresk\MomoApi\Models\AccountBalance; @@ -21,8 +20,6 @@ class DisbursementApi extends AbstractApiProduct { - use InteractsWithHttp; - /** * Create an access token which can then be used to authorize and authenticate * towards the other end-points of the API. From dbf0e0e8d5aa296d2d66407c2740922f30c0edd8 Mon Sep 17 00:00:00 2001 From: lepres Date: Sun, 26 Oct 2025 22:28:05 +0100 Subject: [PATCH 6/6] ci: add PHPStan static analysis to workflow - Run PHPStan before PHPUnit tests - Add push trigger on main branch - Rename workflow to 'Tests' (PHPStan + PHPUnit) --- .github/workflows/phpunit.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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