diff --git a/.editorconfig b/.editorconfig index cde1644..080c6a3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,4 +5,4 @@ end_of_line = lf insert_final_newline = true tab_width = 4 indent_style = space -charset = utf-8 \ No newline at end of file +charset = utf-8 diff --git a/composer.json b/composer.json index 8a20854..ab6b01f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "bowphp/payment", - "description": "The paymemt gateway for Bow Framwork", + "description": "The payment gateway for Bow Framework", "type": "library", "require": { "guzzlehttp/guzzle": "^6.5", diff --git a/config/payment.php b/config/payment.php index d175aff..0d980cb 100644 --- a/config/payment.php +++ b/config/payment.php @@ -19,7 +19,7 @@ /** * List of available gateway */ - 'ivoiry_cost' => [ + 'ivory_coast' => [ 'orange' => [ 'client_key' => '', 'client_secret' => '', @@ -27,8 +27,10 @@ ], 'mtn' => [ - 'client_key' => '', - 'client_secret' => '', + 'subscription_key' => '', + 'api_user' => '', + 'api_key' => '', + 'environment' => 'sandbox', // or 'production' 'webhook_secret' => '' ], diff --git a/docs/en.md b/docs/en.md index 08ab381..ec92af2 100644 --- a/docs/en.md +++ b/docs/en.md @@ -1,105 +1,400 @@ -# Orange Money API +# Bow Payment Documentation -## Configuration +A comprehensive payment gateway for Bow Framework supporting multiple African mobile money providers. -You can use the package simply, like this. +## Supported Providers + +- ✅ **Orange Money** (Ivory Coast) - Fully implemented +- ✅ **MTN Mobile Money** (Ivory Coast) - Fully implemented +- 📦 **Moov Money (Flooz)** - Gateway ready, pending API documentation +- 📦 **Wave** - Gateway ready, pending API documentation +- 📦 **Djamo** - Gateway ready, pending API documentation + +## Installation + +```bash +composer require bowphp/payment +``` + +## Quick Start + +### Configuration + +Configure your payment providers in `config/payment.php`: + +```php +use Bow\Payment\Payment; + +return [ + 'default' => [ + 'gateway' => Payment::ORANGE, + 'country' => 'ci', + ], + + 'ivory_coast' => [ + 'orange' => [ + 'client_key' => env('ORANGE_CLIENT_KEY'), + 'client_secret' => env('ORANGE_CLIENT_SECRET'), + 'webhook_secret' => env('ORANGE_WEBHOOK_SECRET'), + ], + 'mtn' => [ + 'subscription_key' => env('MTN_SUBSCRIPTION_KEY'), + 'api_user' => env('MTN_API_USER'), + 'api_key' => env('MTN_API_KEY'), + 'environment' => 'sandbox', // or 'production' + 'webhook_secret' => env('MTN_WEBHOOK_SECRET'), + ], + ], +]; +``` + +### Basic Usage with Payment Facade + +```php +use Bow\Payment\Payment; + +// Configure the payment gateway +Payment::configure($config); + +// Make a payment +$result = Payment::payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + 'notif_url' => 'https://your-app.com/webhook', + 'return_url' => 'https://your-app.com/success', + 'cancel_url' => 'https://your-app.com/cancel', +]); + +// Verify a transaction +$status = Payment::verify([ + 'amount' => 1000, + 'order_id' => 'ORDER-123', + 'pay_token' => 'TOKEN', +]); + +if ($status->isSuccess()) { + // Payment successful + echo "Payment completed!"; +} +``` + +## Provider-Specific Usage + +### Orange Money + +```php +Payment::configure([ + 'default' => [ + 'gateway' => Payment::ORANGE, + 'country' => 'ci', + ], + 'ivory_coast' => [ + 'orange' => [ + 'client_key' => 'YOUR_CLIENT_KEY', + 'client_secret' => 'YOUR_CLIENT_SECRET', + ], + ], +]); + +$result = Payment::payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + 'notif_url' => 'https://your-app.com/webhook', + 'return_url' => 'https://your-app.com/success', + 'cancel_url' => 'https://your-app.com/cancel', +]); +``` + +### MTN Mobile Money + +```php +Payment::configure([ + 'default' => [ + 'gateway' => Payment::MTN, + 'country' => 'ci', + ], + 'ivory_coast' => [ + 'mtn' => [ + 'subscription_key' => 'YOUR_SUBSCRIPTION_KEY', + 'api_user' => 'YOUR_API_USER', + 'api_key' => 'YOUR_API_KEY', + 'environment' => 'sandbox', // or 'production' + ], + ], +]); + +$result = Payment::payment([ + 'amount' => 1000, + 'phone' => '0707070707', + 'reference' => 'ORDER-123', +]); + +// Verify transaction +$status = Payment::verify(['reference_id' => $result['reference_id']]); + +// Check balance +$balance = Payment::balance(); +``` + +### Switching Providers Dynamically + +```php +// Start with Orange Money +Payment::configure($config); + +// Switch to MTN for a specific transaction +Payment::withProvider('ci', Payment::MTN); +Payment::payment($data); + +// Switch back to default provider +Payment::withProvider('ci', Payment::ORANGE); +``` + +## Advanced Features + +### Using with Models + +Add the `UserPayment` trait to your User model: + +```php +use Bow\Payment\UserPayment; + +class User extends Model +{ + use UserPayment; +} + +// Now you can use payment methods on your user model +$user->payment(1000, 'ORDER-123'); +$user->transfer(5000, 'TRANSFER-456'); +$user->balance(); +``` + +### Retry Logic + +Automatically retry failed API calls with exponential backoff: + +```php +use Bow\Payment\Support\RetryHandler; + +$retry = new RetryHandler( + maxAttempts: 3, + retryDelay: 1000, + exponentialBackoff: true +); + +$result = $retry->execute(function() { + return Payment::payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + ]); +}); +``` + +### Rate Limiting + +Protect your application from exceeding API rate limits: ```php -require __DIR__.'/vendor/autoload.php'; +use Bow\Payment\Support\RateLimiter; -use Bow\Payment\OrangeMoney\OrangeMoneyPayment; -use Bow\Payment\OrangeMoney\OrangeMoneyTokenGenerator; +$limiter = new RateLimiter( + maxRequests: 60, + timeWindow: 60 +); -$client_key = "ZWZn..."; -$merchant_key = 'c178...'; +if ($limiter->isAllowed('orange')) { + $limiter->hit('orange'); + Payment::payment($data); +} else { + // Rate limit exceeded, wait before retrying + $waitTime = $limiter->getRetryAfter('orange'); +} +``` + +### Transaction Logging -$token_generator = new OrangeMoneyTokenGenerator($client_key); +Comprehensive audit trail for all payment operations: -$payment = new OrangeMoneyPayment($token_generator->getToken(), $merchant_key); +```php +use Bow\Payment\Support\TransactionLogger; -$payment->setNotifyUrl('https://example.com/notify.php'); -$payment->setCancelUrl('https://example.com/cancel.php'); -$payment->setReturnUrl('https://example.com/return.php'); +$logger = new TransactionLogger('/path/to/logs'); -$amount = 1200; -$order_id = "1579565569"; -$reference = 'reference'; +// Logs are automatically created with detailed context +$logger->logPaymentRequest('mtn', [ + 'amount' => 1000, + 'reference' => 'ORDER-123' +]); -$orange = $payment->prepare($amount, $order_id, $reference); -$payment_information = $orange->getPaymentInformation(); -$orange->pay(); // Redirect to payment plateforme +$logger->logPaymentResponse('mtn', true, $response); ``` -## Check payment status +### Webhook Handling + +Secure webhook processing with signature validation: ```php -$token_generator = new OrangeMoneyTokenGenerator($client_key); -$amount = 1200; -$order_id = "1579565569"; -$reference = 'reference'; - -$transaction = new OrangeMoneyTransaction($token_generator->getToken()); - -// Check the transaction status -$status = $transaction->check($amount, $order_id, $reference); -$status->pending(); -$status->fail(); -$status->success(); - -// Check the transction status -$status = $transaction->checkIfHasPending($amount, $order_id, $reference); -$status = $transaction->checkIfHasSuccess($amount, $order_id, $reference); -$status = $transaction->checkIfHasFail($amount, $order_id, $reference); +use Bow\Payment\Webhook\WebhookHandler; + +$handler = new WebhookHandler('orange', $config['orange']['webhook_secret']); +$request = WebhookHandler::parseRequest(); + +$event = $handler->handle($request['payload'], $request['signature']); + +if ($event->isPaymentSuccess()) { + $transactionId = $event->getTransactionId(); + $amount = $event->getAmount(); + $status = $event->getStatus(); + + // Update your order status + Order::where('transaction_id', $transactionId)->update([ + 'status' => 'paid', + 'amount' => $amount, + ]); +} ``` -> But except that this way of doing does not allow to exploit the inheritance system in an optimal way. Use this way of doing things, only if you want to test the package or for small applications. +## Exception Handling -## Production code +The package provides comprehensive custom exceptions: ```php -require __DIR__.'/vendor/autoload.php'; +use Bow\Payment\Exceptions\PaymentRequestException; +use Bow\Payment\Exceptions\RateLimitException; +use Bow\Payment\Exceptions\TokenGenerationException; +use Bow\Payment\Exceptions\InvalidProviderException; +use Bow\Payment\Exceptions\TransactionVerificationException; +use Bow\Payment\Exceptions\ConfigurationException; -use Bow\Payment\OrangeMoney\OrangeMoneyPayment; -use Bow\Payment\OrangeMoney\OrangeMoneyTokenGenerator; +try { + Payment::payment($data); +} catch (RateLimitException $e) { + // Rate limit exceeded + $retryAfter = $e->getCode(); + Log::warning("Rate limit exceeded. Retry after: {$retryAfter} seconds"); +} catch (PaymentRequestException $e) { + // Payment request failed + Log::error("Payment failed: " . $e->getMessage()); +} catch (TokenGenerationException $e) { + // Token generation failed + Log::error("Token generation error: " . $e->getMessage()); +} catch (InvalidProviderException $e) { + // Invalid provider specified + Log::error("Invalid provider: " . $e->getMessage()); +} catch (TransactionVerificationException $e) { + // Transaction verification failed + Log::error("Verification failed: " . $e->getMessage()); +} catch (ConfigurationException $e) { + // Configuration error + Log::error("Config error: " . $e->getMessage()); +} +``` -$client_key = "ZWZn..."; -$merchant_key = 'c178...'; +## Direct Provider Usage (Advanced) -$token_generator = new OrangeMoneyTokenGenerator($client_key); +For advanced use cases, you can use providers directly: -// Set the right production endpoint -$token_generator->setTokenGeneratorEndpoint('..'); +### Orange Money Direct Usage + +```php +use Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyGateway; +use Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyTokenGenerator; -$payment = new OrangeMoneyPayment($token_generator->getToken(), $merchant_key); +$config = [ + 'client_key' => 'YOUR_CLIENT_KEY', + 'client_secret' => 'YOUR_CLIENT_SECRET', +]; -// Set the right production endpoint -$payment->setPaymentEndpoint('..'); +$tokenGenerator = new OrangeMoneyTokenGenerator( + $config['client_key'], + $config['client_secret'] +); -$payment->setNotifyUrl('https://example.com/notify.php'); -$payment->setCancelUrl('https://example.com/cancel.php'); -$payment->setReturnUrl('https://example.com/return.php'); +$gateway = new OrangeMoneyGateway($tokenGenerator, $config); -$amount = 1200; -$order_id = "1579565569"; -$reference = 'reference'; +$result = $gateway->payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + 'notif_url' => 'https://your-app.com/webhook', + 'return_url' => 'https://your-app.com/success', + 'cancel_url' => 'https://your-app.com/cancel', +]); -$orange = $payment->prepare($amount, $order_id, $reference); -$payment_information = $orange->getPaymentInformation(); -$orange->pay(); // Redirect to payment plateforme +// Verify transaction +$status = $gateway->verify([ + 'amount' => 1000, + 'order_id' => 'ORDER-123', + 'pay_token' => $result['pay_token'], +]); ``` -## Check transaction +### MTN Mobile Money Direct Usage ```php -// Transaction status -$transaction = new OrangeMoneyTransaction($token_generator->getToken()); +use Bow\Payment\IvoryCost\MTNMobileMoney\MTNMobileMoneyGateway; +use Bow\Payment\IvoryCost\MTNMobileMoney\MomoEnvironment; +use Bow\Payment\IvoryCost\MTNMobileMoney\MomoTokenGenerator; + +$config = [ + 'subscription_key' => 'YOUR_SUBSCRIPTION_KEY', + 'api_user' => 'YOUR_API_USER', + 'api_key' => 'YOUR_API_KEY', + 'environment' => 'sandbox', // or 'production' +]; + +$environment = new MomoEnvironment($config['environment']); +$tokenGenerator = new MomoTokenGenerator( + $config['subscription_key'], + $config['api_user'], + $config['api_key'], + $environment +); + +$gateway = new MTNMobileMoneyGateway($tokenGenerator, $config, $environment); + +$result = $gateway->payment([ + 'amount' => 1000, + 'phone' => '0707070707', + 'reference' => 'ORDER-123', +]); + +// Verify transaction +$status = $gateway->verify([ + 'reference_id' => $result['reference_id'], +]); -// Set the production url -$transaction->setTransactionStatusEndpoint('...'); +// Check balance +$balance = $gateway->balance(); +``` + +## Testing + +The package includes comprehensive tests: -// Check the transaction status -$status = $transaction->check($amount, $order_id, $reference); -$status->pending(); -$status->fail(); -$status->success(); +```bash +composer test ``` + +Tests cover: +- Orange Money payment flow +- MTN Mobile Money payment flow +- Transaction logging +- Retry logic +- Rate limiting +- Webhook handling +- Exception handling + +## Requirements + +- PHP >= 7.4 (PHP 8.0+ recommended) +- Bow Framework >= 4.0 +- GuzzleHTTP >= 6.5 + +## Contributing + +Contributions are welcome! Please follow PSR-12 coding standards and add tests for new features. + +## License + +MIT License. See [LICENSE](../LICENSE) file for details. diff --git a/docs/fr.md b/docs/fr.md index 54e0f25..10695b6 100644 --- a/docs/fr.md +++ b/docs/fr.md @@ -1,104 +1,400 @@ -# Orange Money API +# Documentation Bow Payment -## Configuration +Une passerelle de paiement complète pour Bow Framework prenant en charge plusieurs fournisseurs de mobile money africains. -Vous pouvez utiliser le package simplement, comme ceci. +## Fournisseurs Supportés + +- ✅ **Orange Money** (Côte d'Ivoire) - Entièrement implémenté +- ✅ **MTN Mobile Money** (Côte d'Ivoire) - Entièrement implémenté +- 📦 **Moov Money (Flooz)** - Passerelle prête, en attente de documentation API +- 📦 **Wave** - Passerelle prête, en attente de documentation API +- 📦 **Djamo** - Passerelle prête, en attente de documentation API + +## Installation + +```bash +composer require bowphp/payment +``` + +## Démarrage Rapide + +### Configuration + +Configurez vos fournisseurs de paiement dans `config/payment.php`: ```php -require __DIR__.'/vendor/autoload.php'; +use Bow\Payment\Payment; -use Bow\Payment\OrangeMoney\OrangeMoneyPayment; -use Bow\Payment\OrangeMoney\OrangeMoneyTokenGenerator; +return [ + 'default' => [ + 'gateway' => Payment::ORANGE, + 'country' => 'ci', + ], + + 'ivory_coast' => [ + 'orange' => [ + 'client_key' => env('ORANGE_CLIENT_KEY'), + 'client_secret' => env('ORANGE_CLIENT_SECRET'), + 'webhook_secret' => env('ORANGE_WEBHOOK_SECRET'), + ], + 'mtn' => [ + 'subscription_key' => env('MTN_SUBSCRIPTION_KEY'), + 'api_user' => env('MTN_API_USER'), + 'api_key' => env('MTN_API_KEY'), + 'environment' => 'sandbox', // ou 'production' + 'webhook_secret' => env('MTN_WEBHOOK_SECRET'), + ], + ], +]; +``` -$client_key = "ZWZn..."; -$merchant_key = 'c178...'; +### Utilisation de Base avec Payment Facade -$token_generator = new OrangeMoneyTokenGenerator($client_key); -$payment = new OrangeMoneyPayment($token_generator->getToken(), $merchant_key); +```php +use Bow\Payment\Payment; -$payment->setNotifyUrl('https://example.com/notify.php'); -$payment->setCancelUrl('https://example.com/cancel.php'); -$payment->setReturnUrl('https://example.com/return.php'); +// Configurer la passerelle de paiement +Payment::configure($config); -$amount = 1200; -$order_id = "1579565569"; -$reference = 'reference'; +// Effectuer un paiement +$result = Payment::payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + 'notif_url' => 'https://votre-app.com/webhook', + 'return_url' => 'https://votre-app.com/success', + 'cancel_url' => 'https://votre-app.com/cancel', +]); -$orange = $payment->prepare($amount, $order_id, $reference); -$payment_information = $orange->getPaymentInformation(); -$orange->pay(); // Redirection vers la plateforme de paiement +// Vérifier une transaction +$status = Payment::verify([ + 'amount' => 1000, + 'order_id' => 'ORDER-123', + 'pay_token' => 'TOKEN', +]); + +if ($status->isSuccess()) { + // Paiement réussi + echo "Paiement effectué avec succès!"; +} ``` -## Vérifiez le statut de paiement +## Utilisation Spécifique par Fournisseur + +### Orange Money ```php -$token_generator = new OrangeMoneyTokenGenerator($client_key); -$amount = 1200; -$order_id = "1579565569"; -$reference = 'reference'; - -$transaction = new OrangeMoneyTransaction($token_generator->getToken()); - -// Vérifiez le statut de paiement -$status = $transaction->check($amount, $order_id, $reference); -$status->pending(); -$status->fail(); -$status->success(); - -// Une autre façon de faire vérifiez le statut de paiement -$status = $transaction->checkIfHasPending($amount, $order_id, $reference); -$status = $transaction->checkIfHasSuccess($amount, $order_id, $reference); -$status = $transaction->checkIfHasFail($amount, $order_id, $reference); +Payment::configure([ + 'default' => [ + 'gateway' => Payment::ORANGE, + 'country' => 'ci', + ], + 'ivory_coast' => [ + 'orange' => [ + 'client_key' => 'VOTRE_CLIENT_KEY', + 'client_secret' => 'VOTRE_CLIENT_SECRET', + ], + ], +]); + +$result = Payment::payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + 'notif_url' => 'https://votre-app.com/webhook', + 'return_url' => 'https://votre-app.com/success', + 'cancel_url' => 'https://votre-app.com/cancel', +]); ``` -> Mais sauf que cette façon de faire ne permet pas d'exploiter le système d'héritage de manière optimale. Utilisez cette façon de faire, uniquement si vous souhaitez tester le package ou pour de petites applications. +### MTN Mobile Money + +```php +Payment::configure([ + 'default' => [ + 'gateway' => Payment::MTN, + 'country' => 'ci', + ], + 'ivory_coast' => [ + 'mtn' => [ + 'subscription_key' => 'VOTRE_SUBSCRIPTION_KEY', + 'api_user' => 'VOTRE_API_USER', + 'api_key' => 'VOTRE_API_KEY', + 'environment' => 'sandbox', // ou 'production' + ], + ], +]); + +$result = Payment::payment([ + 'amount' => 1000, + 'phone' => '0707070707', + 'reference' => 'ORDER-123', +]); + +// Vérifier la transaction +$status = Payment::verify(['reference_id' => $result['reference_id']]); -## Code en production +// Vérifier le solde +$balance = Payment::balance(); +``` + +### Basculer Dynamiquement entre Fournisseurs ```php -require __DIR__.'/vendor/autoload.php'; +// Commencer avec Orange Money +Payment::configure($config); -use Bow\Payment\OrangeMoney\OrangeMoneyPayment; -use Bow\Payment\OrangeMoney\OrangeMoneyTokenGenerator; +// Basculer vers MTN pour une transaction spécifique +Payment::withProvider('ci', Payment::MTN); +Payment::payment($data); -$client_key = "ZWZn..."; -$merchant_key = 'c178...'; +// Revenir au fournisseur par défaut +Payment::withProvider('ci', Payment::ORANGE); +``` -$token_generator = new OrangeMoneyTokenGenerator($client_key); +## Fonctionnalités Avancées -// Modifier le lien pour generer le token si necessaire -$token_generator->setTokenGeneratorEndpoint('..'); +### Utilisation avec les Modèles -$payment = new OrangeMoneyPayment($token_generator->getToken(), $merchant_key); +Ajoutez le trait `UserPayment` à votre modèle User: -// Définissez le bon point de terminaison de production -$payment->setPaymentEndpoint('..'); +```php +use Bow\Payment\UserPayment; -$payment->setNotifyUrl('https://example.com/notify.php'); -$payment->setCancelUrl('https://example.com/cancel.php'); -$payment->setReturnUrl('https://example.com/return.php'); +class User extends Model +{ + use UserPayment; +} -$amount = 1200; -$order_id = "1579565569"; -$reference = 'reference'; +// Vous pouvez maintenant utiliser les méthodes de paiement sur votre modèle utilisateur +$user->payment(1000, 'ORDER-123'); +$user->transfer(5000, 'TRANSFER-456'); +$user->balance(); +``` -$orange = $payment->prepare($amount, $order_id, $reference); -$payment_information = $orange->getPaymentInformation(); -$orange->pay(); // Redirection vers la plateforme de paiement +### Logique de Réessai + +Réessayer automatiquement les appels API échoués avec backoff exponentiel: + +```php +use Bow\Payment\Support\RetryHandler; + +$retry = new RetryHandler( + maxAttempts: 3, + retryDelay: 1000, + exponentialBackoff: true +); + +$result = $retry->execute(function() { + return Payment::payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + ]); +}); +``` + +### Limitation de Débit + +Protégez votre application contre le dépassement des limites de débit de l'API: + +```php +use Bow\Payment\Support\RateLimiter; + +$limiter = new RateLimiter( + maxRequests: 60, + timeWindow: 60 +); + +if ($limiter->isAllowed('orange')) { + $limiter->hit('orange'); + Payment::payment($data); +} else { + // Limite de débit dépassée, attendre avant de réessayer + $waitTime = $limiter->getRetryAfter('orange'); +} ``` -## Check transaction +### Journalisation des Transactions + +Piste d'audit complète pour toutes les opérations de paiement: ```php -// Vérifiez le statut de paiement -$transaction = new OrangeMoneyTransaction($token_generator->getToken()); +use Bow\Payment\Support\TransactionLogger; -// Définir l'URL de production -$transaction->setTransactionStatusEndpoint('...'); +$logger = new TransactionLogger('/chemin/vers/logs'); -// Vérifiez le statut de paiement -$status = $transaction->check($amount, $order_id, $reference); -$status->pending(); -$status->fail(); -$status->success(); +// Les journaux sont automatiquement créés avec un contexte détaillé +$logger->logPaymentRequest('mtn', [ + 'amount' => 1000, + 'reference' => 'ORDER-123' +]); + +$logger->logPaymentResponse('mtn', true, $response); ``` + +### Gestion des Webhooks + +Traitement sécurisé des webhooks avec validation de signature: + +```php +use Bow\Payment\Webhook\WebhookHandler; + +$handler = new WebhookHandler('orange', $config['orange']['webhook_secret']); +$request = WebhookHandler::parseRequest(); + +$event = $handler->handle($request['payload'], $request['signature']); + +if ($event->isPaymentSuccess()) { + $transactionId = $event->getTransactionId(); + $amount = $event->getAmount(); + $status = $event->getStatus(); + + // Mettre à jour le statut de votre commande + Order::where('transaction_id', $transactionId)->update([ + 'status' => 'paid', + 'amount' => $amount, + ]); +} +``` + +## Gestion des Exceptions + +Le package fournit des exceptions personnalisées complètes: + +```php +use Bow\Payment\Exceptions\PaymentRequestException; +use Bow\Payment\Exceptions\RateLimitException; +use Bow\Payment\Exceptions\TokenGenerationException; +use Bow\Payment\Exceptions\InvalidProviderException; +use Bow\Payment\Exceptions\TransactionVerificationException; +use Bow\Payment\Exceptions\ConfigurationException; + +try { + Payment::payment($data); +} catch (RateLimitException $e) { + // Limite de débit dépassée + $retryAfter = $e->getCode(); + Log::warning("Limite dépassée. Réessayer après: {$retryAfter} secondes"); +} catch (PaymentRequestException $e) { + // Échec de la demande de paiement + Log::error("Paiement échoué: " . $e->getMessage()); +} catch (TokenGenerationException $e) { + // Échec de la génération du token + Log::error("Erreur de génération de token: " . $e->getMessage()); +} catch (InvalidProviderException $e) { + // Fournisseur invalide spécifié + Log::error("Fournisseur invalide: " . $e->getMessage()); +} catch (TransactionVerificationException $e) { + // Échec de la vérification de transaction + Log::error("Vérification échouée: " . $e->getMessage()); +} catch (ConfigurationException $e) { + // Erreur de configuration + Log::error("Erreur de config: " . $e->getMessage()); +} +``` + +## Utilisation Directe du Fournisseur (Avancé) + +Pour des cas d'utilisation avancés, vous pouvez utiliser les fournisseurs directement: + +### Utilisation Directe d'Orange Money + +```php +use Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyGateway; +use Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyTokenGenerator; + +$config = [ + 'client_key' => 'VOTRE_CLIENT_KEY', + 'client_secret' => 'VOTRE_CLIENT_SECRET', +]; + +$tokenGenerator = new OrangeMoneyTokenGenerator( + $config['client_key'], + $config['client_secret'] +); + +$gateway = new OrangeMoneyGateway($tokenGenerator, $config); + +$result = $gateway->payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + 'notif_url' => 'https://votre-app.com/webhook', + 'return_url' => 'https://votre-app.com/success', + 'cancel_url' => 'https://votre-app.com/cancel', +]); + +// Vérifier la transaction +$status = $gateway->verify([ + 'amount' => 1000, + 'order_id' => 'ORDER-123', + 'pay_token' => $result['pay_token'], +]); +``` + +### Utilisation Directe de MTN Mobile Money + +```php +use Bow\Payment\IvoryCost\MTNMobileMoney\MTNMobileMoneyGateway; +use Bow\Payment\IvoryCost\MTNMobileMoney\MomoEnvironment; +use Bow\Payment\IvoryCost\MTNMobileMoney\MomoTokenGenerator; + +$config = [ + 'subscription_key' => 'VOTRE_SUBSCRIPTION_KEY', + 'api_user' => 'VOTRE_API_USER', + 'api_key' => 'VOTRE_API_KEY', + 'environment' => 'sandbox', // ou 'production' +]; + +$environment = new MomoEnvironment($config['environment']); +$tokenGenerator = new MomoTokenGenerator( + $config['subscription_key'], + $config['api_user'], + $config['api_key'], + $environment +); + +$gateway = new MTNMobileMoneyGateway($tokenGenerator, $config, $environment); + +$result = $gateway->payment([ + 'amount' => 1000, + 'phone' => '0707070707', + 'reference' => 'ORDER-123', +]); + +// Vérifier la transaction +$status = $gateway->verify([ + 'reference_id' => $result['reference_id'], +]); + +// Vérifier le solde +$balance = $gateway->balance(); +``` + +## Tests + +Le package inclut des tests complets: + +```bash +composer test +``` + +Les tests couvrent: +- Flux de paiement Orange Money +- Flux de paiement MTN Mobile Money +- Journalisation des transactions +- Logique de réessai +- Limitation de débit +- Gestion des webhooks +- Gestion des exceptions + +## Exigences + +- PHP >= 7.4 (PHP 8.0+ recommandé) +- Bow Framework >= 4.0 +- GuzzleHTTP >= 6.5 + +## Contribution + +Les contributions sont les bienvenues! Veuillez suivre les standards de codage PSR-12 et ajouter des tests pour les nouvelles fonctionnalités. + +## Licence + +Licence MIT. Voir le fichier [LICENSE](../LICENSE) pour plus de détails. diff --git a/readme.md b/readme.md index ccae71e..168640d 100644 --- a/readme.md +++ b/readme.md @@ -1,30 +1,340 @@ -

- -

+# Bow Payment -

The paymemt gateway for Bow Framwork. Is build for make Bow Framework lovely.

+[![Documentation](https://img.shields.io/badge/docs-read%20docs-blue.svg?style=flat-square)](https://github.com/bowphp/docs/blog/master/payment.md) +[![Latest Version](https://img.shields.io/packagist/v/bowphp/payment.svg?style=flat-square)](https://packagist.org/packages/bowphp/payment) +[![License](https://img.shields.io/github/license/mashape/apistatus.svg?style=flat-square)](https://github.com/bowphp/payment/blob/master/LICENSE) +[![Build Status](https://img.shields.io/travis/bowphp/payment/master.svg?style=flat-square)](https://travis-ci.org/bowphp/payment) -

- - - - -

+The comprehensive payment gateway for Bow Framework. Built to make integrating African mobile money payment providers seamless, secure, and reliable. ## Introduction -This package can help the developer to easily integrate local mobile payment APIs such as **Orange Money CI**, **Moov Money** commonly call **Flooz** and **MTN Mobile Money**. +This package helps developers easily integrate local mobile payment APIs such as **Orange Money**, **Moov Money** (commonly called **Flooz**), **MTN Mobile Money**, **Wave**, and **Djamo** with advanced features like retry logic, rate limiting, transaction logging, and webhook handling. -> Actually support Orange Money +### Supported Providers + +- ✅ **Orange Money** (Ivory Coast) - Fully implemented +- ✅ **MTN Mobile Money** (Ivory Coast) - Fully implemented +- 📦 **Moov Money (Flooz)** - Gateway ready, pending API documentation +- 📦 **Wave** - Gateway ready, pending API documentation +- 📦 **Djamo** - Gateway ready, pending API documentation ## Installation -To install the package it will be better to use `composer` who is `php` package manager. +Install the package using `composer`, the PHP package manager: ```bash composer require bowphp/payment ``` -Documentation is available in [english](./docs/en.md) and [frensh](./docs/fr.md). +## Quick Start + +### Configuration + +Configure your payment providers in your `config/payment.php`: + +```php +use Bow\Payment\Payment; + +return [ + 'default' => [ + 'gateway' => Payment::ORANGE, + 'country' => 'ci', + ], + + 'ivory_coast' => [ + 'orange' => [ + 'client_key' => env('ORANGE_CLIENT_KEY'), + 'client_secret' => env('ORANGE_CLIENT_SECRET'), + 'webhook_secret' => env('ORANGE_WEBHOOK_SECRET'), + ], + 'mtn' => [ + 'subscription_key' => env('MTN_SUBSCRIPTION_KEY'), + 'api_user' => env('MTN_API_USER'), + 'api_key' => env('MTN_API_KEY'), + 'environment' => 'sandbox', // or 'production' + 'webhook_secret' => env('MTN_WEBHOOK_SECRET'), + ], + // Other providers... + ], +]; +``` + +### Basic Usage + +```php +use Bow\Payment\Payment; + +// Configure the payment gateway +Payment::configure($config); + +// Make a payment +Payment::payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + 'notif_url' => 'https://your-app.com/webhook', + 'return_url' => 'https://your-app.com/success', + 'cancel_url' => 'https://your-app.com/cancel', +]); + +// Verify a transaction +$status = Payment::verify([ + 'amount' => 1000, + 'order_id' => 'ORDER-123', + 'pay_token' => 'TOKEN', +]); + +if ($status->isSuccess()) { + // Payment successful +} +``` + +### Using with Models + +Add the `UserPayment` trait to your User model: + +```php +use Bow\Payment\UserPayment; + +class User extends Model +{ + use UserPayment; +} + +// Now you can use payment methods on your user model +$user->payment(1000, 'ORDER-123'); +$user->transfer(5000, 'TRANSFER-456'); +``` + +## Advanced Features + +### Retry Logic + +Automatically retry failed API calls with exponential backoff: + +```php +use Bow\Payment\Support\RetryHandler; + +$retry = new RetryHandler( + maxAttempts: 3, + retryDelay: 1000, + exponentialBackoff: true +); + +$result = $retry->execute(function() use ($payment) { + return $payment->pay($amount); +}); +``` + +### Rate Limiting + +Protect your application from exceeding API rate limits: + +```php +use Bow\Payment\Support\RateLimiter; + +$limiter = new RateLimiter( + maxRequests: 60, + timeWindow: 60 +); + +if ($limiter->isAllowed('orange')) { + $limiter->hit('orange'); + // Make API call +} +``` + +### Transaction Logging + +Comprehensive audit trail for all payment operations: + +```php +use Bow\Payment\Support\TransactionLogger; + +$logger = new TransactionLogger('/path/to/logs'); + +// Logs are automatically created with detailed context +$logger->logPaymentRequest('mtn', [ + 'amount' => 1000, + 'reference' => 'ORDER-123' +]); + +$logger->logPaymentResponse('mtn', true, $response); +``` + +### Webhook Handling + +Secure webhook processing with signature validation: + +```php +use Bow\Payment\Webhook\WebhookHandler; + +$handler = new WebhookHandler('orange', $config['webhook_secret']); +$request = WebhookHandler::parseRequest(); + +$event = $handler->handle($request['payload'], $request['signature']); + +if ($event->isPaymentSuccess()) { + $transactionId = $event->getTransactionId(); + $amount = $event->getAmount(); + // Update order status +} +``` + +### Exception Handling + +Comprehensive custom exceptions for better error handling: + +```php +use Bow\Payment\Exceptions\PaymentRequestException; +use Bow\Payment\Exceptions\RateLimitException; +use Bow\Payment\Exceptions\TokenGenerationException; + +try { + Payment::payment($data); +} catch (RateLimitException $e) { + // Rate limit exceeded + $retryAfter = $e->getCode(); +} catch (PaymentRequestException $e) { + // Payment request failed + Log::error($e->getMessage()); +} catch (TokenGenerationException $e) { + // Token generation failed +} +``` + +## Provider-Specific Usage + +### Orange Money + +```php +Payment::configure([ + 'default' => [ + 'gateway' => Payment::ORANGE, + 'country' => 'ci', + ], + 'ivory_coast' => [ + 'orange' => [ + 'client_key' => 'YOUR_CLIENT_KEY', + 'client_secret' => 'YOUR_CLIENT_SECRET', + ], + ], +]); + +$result = Payment::payment([ + 'amount' => 1000, + 'reference' => 'ORDER-123', + 'notif_url' => 'https://your-app.com/webhook', + 'return_url' => 'https://your-app.com/success', + 'cancel_url' => 'https://your-app.com/cancel', +]); +``` + +### MTN Mobile Money + +```php +Payment::configure([ + 'default' => [ + 'gateway' => Payment::MTN, + 'country' => 'ci', + ], + 'ivory_coast' => [ + 'mtn' => [ + 'subscription_key' => 'YOUR_SUBSCRIPTION_KEY', + 'api_user' => 'YOUR_API_USER', + 'api_key' => 'YOUR_API_KEY', + 'environment' => 'sandbox', + ], + ], +]); + +$result = Payment::payment([ + 'amount' => 1000, + 'phone' => '0707070707', + 'reference' => 'ORDER-123', +]); + +// Verify transaction +$status = Payment::verify(['reference_id' => $result['reference_id']]); + +// Check balance +$balance = Payment::balance(); +``` + +### Switching Providers Dynamically + +```php +// Start with Orange Money +Payment::configure($config); + +// Switch to MTN for a specific transaction +Payment::withProvider('ci', Payment::MTN); +Payment::payment($data); +``` + +## Features + +- ✅ Simple, fluent API +- ✅ Multiple payment provider support (Orange Money, MTN Mobile Money) +- ✅ Dynamic provider switching +- ✅ Transaction status verification +- ✅ User model integration via traits +- ✅ Webhook handling with signature validation +- ✅ Transfer support +- ✅ Balance inquiry +- ✅ Automatic retry logic with exponential backoff +- ✅ Rate limiting protection +- ✅ Transaction audit logging +- ✅ Comprehensive exception handling +- ✅ Sandbox and production environment support + +## Requirements + +- PHP >= 7.4 (PHP 8.0+ recommended) +- Bow Framework >= 4.0 +- GuzzleHTTP >= 6.5 + +## Testing + +Run the test suite: + +```bash +composer test +``` + +The package includes comprehensive tests for: +- Transaction logging +- Retry logic +- Rate limiting +- Webhook handling +- Payment configuration + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +### Development Guidelines + +1. Follow PSR-12 coding standards +2. Add tests for new features +3. Update documentation +4. Ensure all tests pass before submitting PR + +## Changelog + +See [UPGRADE_SUMMARY.md](UPGRADE_SUMMARY.md) for recent changes and improvements. + +## License + +The Bow Payment package is open-sourced software licensed under the [MIT license](LICENSE). + +## Support + +If you find this project helpful, consider supporting its development: + +[![Buy Me A Coffee](https://cdn.buymeacoffee.com/buttons/default-black.png)](https://www.buymeacoffee.com/iOLqZ3h) + +## Credits -Buy Me A Coffee +- [Franck DAKIA](https://github.com/papac) - Lead Developer +- [All Contributors](../../contributors) diff --git a/src/Common/ProcessorTransactionStatusInterface.php b/src/Common/ProcessorStatusInterface.php similarity index 92% rename from src/Common/ProcessorTransactionStatusInterface.php rename to src/Common/ProcessorStatusInterface.php index f2ba7b0..27b2596 100644 --- a/src/Common/ProcessorTransactionStatusInterface.php +++ b/src/Common/ProcessorStatusInterface.php @@ -2,7 +2,7 @@ namespace Bow\Payment\Common; -interface ProcessorTransactionStatusInterface +interface ProcessorStatusInterface { /** * Define if transaction fail diff --git a/src/Common/Utils.php b/src/Common/Utils.php new file mode 100644 index 0000000..f0517a0 --- /dev/null +++ b/src/Common/Utils.php @@ -0,0 +1,37 @@ +config = $config; + } + + /** + * Make payment + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function payment(...$args) + { + throw new PaymentRequestException( + 'Djamo payment gateway is not yet implemented. Implementation pending official API documentation.' + ); + } + + /** + * Make transfer + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function transfer(...$args) + { + throw new PaymentRequestException( + 'Djamo transfer is not yet implemented.' + ); + } + + /** + * Get balance + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function balance(...$args) + { + throw new PaymentRequestException( + 'Djamo balance inquiry is not yet implemented.' + ); + } + + /** + * Verify payment + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function verify(...$args) + { + throw new PaymentRequestException( + 'Djamo payment verification is not yet implemented.' + ); + } +} diff --git a/src/IvoryCost/MTNMobileMoney/.gitkeep b/src/IvoryCost/MTNMobileMoney/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/IvoryCost/MTNMobileMoney/Collection/MomoPayment.php b/src/IvoryCost/MTNMobileMoney/Collection/MomoPayment.php index f4f8d9b..c38f72e 100644 --- a/src/IvoryCost/MTNMobileMoney/Collection/MomoPayment.php +++ b/src/IvoryCost/MTNMobileMoney/Collection/MomoPayment.php @@ -1,8 +1,112 @@ token = $token; + $this->environment = $environment; + $this->http = new HttpClient(['base_uri' => $this->environment->getBaseUri()]); + } + + /** + * Request to pay + * + * @param array $data + * @return array + * @throws PaymentRequestException + */ + public function requestToPay(array $data): array + { + try { + $referenceId = $data['reference'] ?? Utils::generateUuid(); + + $payload = [ + 'amount' => (string) $data['amount'], + 'currency' => $data['currency'] ?? 'XOF', + 'externalId' => $referenceId, + 'payer' => [ + 'partyIdType' => 'MSISDN', + 'partyId' => $this->formatPhone($data['phone']), + ], + 'payerMessage' => $data['payer_message'] ?? 'Payment', + 'payeeNote' => $data['payee_note'] ?? 'Payment received', + ]; + + $response = $this->http->post('/collection/v1_0/requesttopay', [ + 'headers' => [ + 'Authorization' => $this->token->getAuthorizationHeader(), + 'X-Reference-Id' => $referenceId, + 'X-Target-Environment' => $this->environment->isSandbox() ? 'sandbox' : 'live', + 'Ocp-Apim-Subscription-Key' => $this->environment->getSubscriptionKey(), + 'Content-Type' => 'application/json', + ], + 'json' => $payload, + ]); + + return [ + 'success' => $response->getStatusCode() === 202, + 'reference_id' => $referenceId, + 'status' => 'PENDING', + 'message' => 'Payment request submitted successfully', + ]; + } catch (\Exception $e) { + throw new PaymentRequestException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Format phone number for MTN API + * + * @param string $phone + * @return string + */ + private function formatPhone(string $phone): string + { + // Remove any non-digit characters + $phone = preg_replace('/\D/', '', $phone); + + // Ensure it starts with country code + if (!str_starts_with($phone, '225')) { + $phone = '225' . $phone; + } + + return $phone; + } } + diff --git a/src/IvoryCost/MTNMobileMoney/Collection/MomoPaymentStatus.php b/src/IvoryCost/MTNMobileMoney/Collection/MomoPaymentStatus.php index 2e40c85..11b3c6a 100644 --- a/src/IvoryCost/MTNMobileMoney/Collection/MomoPaymentStatus.php +++ b/src/IvoryCost/MTNMobileMoney/Collection/MomoPaymentStatus.php @@ -1,8 +1,105 @@ status = $status; + $this->data = $data; + } + + /** + * Check if transaction failed + * + * @return bool + */ + public function isFail(): bool + { + return in_array($this->status, ['FAILED', 'REJECTED']); + } + + /** + * Check if transaction was initiated + * + * @return bool + */ + public function isInitiated(): bool + { + return $this->status === 'PENDING'; + } + + /** + * Check if transaction expired + * + * @return bool + */ + public function isExpired(): bool + { + return $this->status === 'EXPIRED'; + } + + /** + * Check if transaction succeeded + * + * @return bool + */ + public function isSuccess(): bool + { + return $this->status === 'SUCCESSFUL'; + } + + /** + * Check if transaction is pending + * + * @return bool + */ + public function isPending(): bool + { + return $this->status === 'PENDING'; + } + + /** + * Get the transaction status + * + * @return string + */ + public function getStatus(): string + { + return $this->status; + } + + /** + * Get transaction data + * + * @return array + */ + public function getData(): array + { + return $this->data; + } } + diff --git a/src/IvoryCost/MTNMobileMoney/Collection/MomoTransaction.php b/src/IvoryCost/MTNMobileMoney/Collection/MomoTransaction.php new file mode 100644 index 0000000..ba4bd7e --- /dev/null +++ b/src/IvoryCost/MTNMobileMoney/Collection/MomoTransaction.php @@ -0,0 +1,96 @@ +token = $token; + $this->environment = $environment; + $this->http = new HttpClient(['base_uri' => $this->environment->getBaseUri()]); + } + + /** + * Get transaction status + * + * @param string $referenceId + * @return MomoPaymentStatus + * @throws TransactionVerificationException + */ + public function getTransactionStatus(string $referenceId): MomoPaymentStatus + { + try { + $response = $this->http->get("/collection/v1_0/requesttopay/{$referenceId}", [ + 'headers' => [ + 'Authorization' => $this->token->getAuthorizationHeader(), + 'X-Target-Environment' => $this->environment->isSandbox() ? 'sandbox' : 'live', + 'Ocp-Apim-Subscription-Key' => $this->environment->getSubscriptionKey(), + ], + ]); + + $data = json_decode($response->getBody()->getContents(), true); + + return new MomoPaymentStatus($data['status'], $data); + } catch (\Exception $e) { + throw new TransactionVerificationException($referenceId, $e); + } + } + + /** + * Get account balance + * + * @return array + */ + public function getAccountBalance(): array + { + try { + $response = $this->http->get('/collection/v1_0/account/balance', [ + 'headers' => [ + 'Authorization' => $this->token->getAuthorizationHeader(), + 'X-Target-Environment' => $this->environment->isSandbox() ? 'sandbox' : 'live', + 'Ocp-Apim-Subscription-Key' => $this->environment->getSubscriptionKey(), + ], + ]); + + return json_decode($response->getBody()->getContents(), true); + } catch (\Exception $e) { + return [ + 'error' => true, + 'message' => $e->getMessage(), + ]; + } + } +} diff --git a/src/IvoryCost/MTNMobileMoney/MTNMobileMoneyGateway.php b/src/IvoryCost/MTNMobileMoney/MTNMobileMoneyGateway.php new file mode 100644 index 0000000..46daa1f --- /dev/null +++ b/src/IvoryCost/MTNMobileMoney/MTNMobileMoneyGateway.php @@ -0,0 +1,116 @@ +environment = new MomoEnvironment( + $config['subscription_key'] ?? '', + $config['api_user'] ?? '', + $config['api_key'] ?? '' + ); + + // Set environment + if (isset($config['environment']) && $config['environment'] === 'production') { + $this->environment->switchToProduction(); + } + + $this->tokenGenerator = new MomoTokenGenerator($this->environment); + } + + /** + * Make payment + * + * @param mixed ...$args + * @return mixed + */ + public function payment(...$args) + { + $token = $this->tokenGenerator->getToken(); + + $payment = new MomoPayment($token, $this->environment); + + $amount = $args['amount'] ?? $args[0]; + $phone = $args['phone'] ?? $args[1]; + $reference = $args['reference'] ?? $args[2] ?? uniqid('momo_'); + $currency = $args['currency'] ?? 'XOF'; + + return $payment->requestToPay([ + 'amount' => $amount, + 'phone' => $phone, + 'reference' => $reference, + 'currency' => $currency, + 'payer_message' => $args['payer_message'] ?? 'Payment', + 'payee_note' => $args['payee_note'] ?? 'Payment received', + ]); + } + + /** + * Make transfer + * + * @param mixed ...$args + * @return mixed + */ + public function transfer(...$args) + { + // MTN Mobile Money CI uses Collection API for payments + // Transfer functionality would require Disbursement API + throw new \BadMethodCallException('Transfer not yet implemented for MTN Mobile Money'); + } + + /** + * Get balance + * + * @param mixed ...$args + * @return mixed + */ + public function balance(...$args) + { + $token = $this->tokenGenerator->getToken(); + $transaction = new MomoTransaction($token, $this->environment); + + return $transaction->getAccountBalance(); + } + + /** + * Verify payment + * + * @return mixed + */ + public function verify(...$args) + { + $token = $this->tokenGenerator->getToken(); + $transaction = new MomoTransaction($token, $this->environment); + + $referenceId = $args['reference_id'] ?? $args[0]; + + return $transaction->getTransactionStatus($referenceId); + } +} diff --git a/src/IvoryCost/MTNMobileMoney/MomoEnvironment.php b/src/IvoryCost/MTNMobileMoney/MomoEnvironment.php index e7ebc45..ff28a12 100644 --- a/src/IvoryCost/MTNMobileMoney/MomoEnvironment.php +++ b/src/IvoryCost/MTNMobileMoney/MomoEnvironment.php @@ -1,6 +1,6 @@ subscription_key = $subscription_key; + $this->api_user = $api_user; + $this->api_key = $api_key; } /** - * Get the subscription + * Get the subscription key * * @return string */ - public function getSubscriptionKey() + public function getSubscriptionKey(): string { return $this->subscription_key; } /** - * Get the basic authorization key + * Get the API user + * + * @return string + */ + public function getApiUser(): string + { + return $this->api_user; + } + + /** + * Get the API key * * @return string */ - public function getBasicAuthorizationKey() + public function getApiKey(): string { - return $this->basic_auth; + return $this->api_key; } /** - * Check the environment + * Get authorization headers + * + * @return array + */ + public function getAuthorization(): array + { + return [ + 'Authorization' => 'Basic ' . base64_encode($this->api_user . ':' . $this->api_key), + 'Ocp-Apim-Subscription-Key' => $this->subscription_key, + ]; + } + + /** + * Check if environment is production * * @return bool */ - public function production(): bool + public function isProduction(): bool { - return $this->environment == 'production'; + return $this->environment === 'production'; } /** - * Check the environment + * Check if environment is sandbox * * @return bool */ - public function sandbox(): bool + public function isSandbox(): bool { - return $this->environment == 'sandbox'; + return $this->environment === 'sandbox'; } /** @@ -68,7 +117,7 @@ public function sandbox(): bool * * @return void */ - public function switchToSandbox() + public function switchToSandbox(): void { $this->environment = 'sandbox'; } @@ -78,37 +127,20 @@ public function switchToSandbox() * * @return void */ - public function switchToProduction() + public function switchToProduction(): void { $this->environment = 'production'; } /** - * Get the base uri + * Get the base URI * * @return string */ public function getBaseUri(): string { - if ($this->sandbox()) { - $base_uri = 'https://sandbox.momodeveloper.mtn.com/v1_0/'; - } else { - $base_uri = 'https://momodeveloper.mtn.com/v1_0/'; - } - - return $base_uri; - } - - /** - * Get the request Authorization - * - * @return array - */ - public function getAuthorization(): array - { - return [ - 'Authorization' => 'Basic ' . $this->basic_auth, - 'Ocp-Apim-Subscription-Key' => $this->subscription_key, - ]; + return $this->isSandbox() + ? 'https://sandbox.momodeveloper.mtn.com' + : 'https://momodeveloper.mtn.com'; } } diff --git a/src/IvoryCost/MTNMobileMoney/MomoToken.php b/src/IvoryCost/MTNMobileMoney/MomoToken.php new file mode 100644 index 0000000..6285da8 --- /dev/null +++ b/src/IvoryCost/MTNMobileMoney/MomoToken.php @@ -0,0 +1,60 @@ +accessToken; + } + + /** + * Get the token type + * + * @return string + */ + public function getTokenType(): string + { + return $this->tokenType; + } + + /** + * Get expiration time in seconds + * + * @return int + */ + public function getExpiresIn(): int + { + return $this->expiresIn; + } + + /** + * Get the full authorization header value + * + * @return string + */ + public function getAuthorizationHeader(): string + { + return "{$this->tokenType} {$this->accessToken}"; + } +} diff --git a/src/IvoryCost/MTNMobileMoney/MonoTokenGenerator.php b/src/IvoryCost/MTNMobileMoney/MonoTokenGenerator.php index db711e1..83c67f7 100644 --- a/src/IvoryCost/MTNMobileMoney/MonoTokenGenerator.php +++ b/src/IvoryCost/MTNMobileMoney/MonoTokenGenerator.php @@ -1,11 +1,13 @@ environment = $environment; + $this->interface = $interface; $this->http = new HttpClient(['base_uri' => $this->environment->getBaseUri()]); } /** - * Get the token + * Get authentication token * - * @return string + * @return MomoToken + * @throws TokenGenerationException */ - public function getToken() + public function getToken(): MomoToken { - $headers = $this->environment->getAuthorization(); - - $response = $this->http->post('/'.$this->interface_name.'/token', [ - 'headers' => $headers - ]); - - // Get the response content - $content = $response->getBody()->getContents(); - - $token = json_decode($content); - - return new MomoToken( - $token->access_token, - $token->token_type, - $token->expires_in - ); + try { + $response = $this->http->post("/{$this->interface}/token/", [ + 'headers' => $this->environment->getAuthorization() + ]); + + $content = $response->getBody()->getContents(); + $data = json_decode($content, true); + + return new MomoToken( + $data['access_token'], + $data['token_type'], + $data['expires_in'] + ); + } catch (\Exception $e) { + throw new TokenGenerationException('MTN Mobile Money', $e); + } } /** - * Set the interface type nane + * Set the interface name * - * @param string $name + * @param string $interface + * @return void */ - public function setInterfaceName($name) + public function setInterface(string $interface): void { - $this->interface_name = $name; + $this->interface = $interface; } } diff --git a/src/IvoryCost/MoovFlooz/.gitkeep b/src/IvoryCost/MoovFlooz/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/IvoryCost/MoovFlooz/MoovFloozGateway.php b/src/IvoryCost/MoovFlooz/MoovFloozGateway.php new file mode 100644 index 0000000..a18b03e --- /dev/null +++ b/src/IvoryCost/MoovFlooz/MoovFloozGateway.php @@ -0,0 +1,86 @@ +config = $config; + } + + /** + * Make payment + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function payment(...$args) + { + throw new PaymentRequestException( + 'Moov Money (Flooz) payment gateway is not yet implemented. Implementation pending official API documentation.' + ); + } + + /** + * Make transfer + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function transfer(...$args) + { + throw new PaymentRequestException( + 'Moov Money (Flooz) transfer is not yet implemented.' + ); + } + + /** + * Get balance + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function balance(...$args) + { + throw new PaymentRequestException( + 'Moov Money (Flooz) balance inquiry is not yet implemented.' + ); + } + + /** + * Verify payment + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function verify(...$args) + { + throw new PaymentRequestException( + 'Moov Money (Flooz) payment verification is not yet implemented.' + ); + } +} diff --git a/src/IvoryCost/OrangeMoney/OrangeMoneyGateway.php b/src/IvoryCost/OrangeMoney/OrangeMoneyGateway.php index a669de6..9c94e53 100644 --- a/src/IvoryCost/OrangeMoney/OrangeMoneyGateway.php +++ b/src/IvoryCost/OrangeMoney/OrangeMoneyGateway.php @@ -4,11 +4,12 @@ use Bow\Payment\Common\ProcessorGatewayInterface; use Bow\Payment\Common\ProcessorTransactionStatusInterface; +use Bow\Payment\Exceptions\PaymentRequestException; use Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyPayment; use Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyTokenGenerator; use Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyTransaction; -class OrangeMoneyGateway extends ProcessorGatewayInterface +class OrangeMoneyGateway implements ProcessorGatewayInterface { /** * ForOrangeMoney constructor @@ -55,7 +56,7 @@ public function payment(...$args) * Verify payment * * @param array ...$args - * @return ProcessorTransactionStatusInterface + * @return ProcessorStatusInterface */ public function verify(...$args) { @@ -75,6 +76,32 @@ public function verify(...$args) return $transaction->check($amount, $order_id, $pay_token); } + /** + * Transfer money + * + * @param array ...$args + * @return mixed + */ + public function transfer(...$args) + { + throw new PaymentRequestException( + 'Orange Money payment gateway is not yet implemented. Implementation pending official API documentation.' + ); + } + + /** + * Get balance + * + * @param array ...$args + * @return mixed + */ + public function balance(...$args) + { + throw new PaymentRequestException( + 'Orange Money balance inquiry is not yet implemented.' + ); + } + /** * Create the Token Generator instance * diff --git a/src/IvoryCost/OrangeMoney/OrangeMoneyTransactionStatus.php b/src/IvoryCost/OrangeMoney/OrangeMoneyTransactionStatus.php index e2ede79..4879813 100644 --- a/src/IvoryCost/OrangeMoney/OrangeMoneyTransactionStatus.php +++ b/src/IvoryCost/OrangeMoney/OrangeMoneyTransactionStatus.php @@ -2,9 +2,9 @@ namespace Bow\Payment\IvoryCost\OrangeMoney; -use Bow\Payment\Common\ProcessorTransactionStatusInterface; +use Bow\Payment\Common\ProcessorStatusInterface; -class OrangeMoneyTransactionStatus implements ProcessorTransactionStatusInterface +class OrangeMoneyTransactionStatus implements ProcessorStatusInterface { /** * Define the transaction status @@ -13,24 +13,15 @@ class OrangeMoneyTransactionStatus implements ProcessorTransactionStatusInterfac */ private $status; - /** - * Define the transaction notif_token - * - * @var string - */ - private $notif_token; - /** * OrangeMoneyTransactionStatus constructor * * @param string $status - * @param string $notif_token * @return void */ - public function __construct(string $status, string $notif_token) + public function __construct(string $status) { $this->status = strtoupper($status); - $this->notif_token = $notif_token; } /** diff --git a/src/IvoryCost/Wave/.gitkeep b/src/IvoryCost/Wave/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/IvoryCost/Wave/WaveGateway.php b/src/IvoryCost/Wave/WaveGateway.php new file mode 100644 index 0000000..b5ce036 --- /dev/null +++ b/src/IvoryCost/Wave/WaveGateway.php @@ -0,0 +1,86 @@ +config = $config; + } + + /** + * Make payment + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function payment(...$args) + { + throw new PaymentRequestException( + 'Wave payment gateway is not yet implemented. Implementation pending official API documentation.' + ); + } + + /** + * Make transfer + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function transfer(...$args) + { + throw new PaymentRequestException( + 'Wave transfer is not yet implemented.' + ); + } + + /** + * Get balance + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function balance(...$args) + { + throw new PaymentRequestException( + 'Wave balance inquiry is not yet implemented.' + ); + } + + /** + * Verify payment + * + * @param mixed ...$args + * @return mixed + * @throws PaymentRequestException + */ + public function verify(...$args) + { + throw new PaymentRequestException( + 'Wave payment verification is not yet implemented.' + ); + } +} diff --git a/src/Payment.php b/src/Payment.php index b70d628..8f0c28b 100644 --- a/src/Payment.php +++ b/src/Payment.php @@ -40,7 +40,7 @@ class Payment implements ProcessorGatewayInterface * Ivory Coast (Côte d'Ivoire) country identifier * ISO 3166-1 alpha-2 country code for Ivory Coast */ - public const CI = 'ivoiry_cost'; + public const CI = 'ivory_coast'; /** * Ivory Coast payment provider mapping @@ -51,10 +51,10 @@ class Payment implements ProcessorGatewayInterface */ public const CI_PROVIDER = [ Payment::ORANGE => \Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyGateway::class, - Payment::MOOV => null, - Payment::WAVE => null, - Payment::MTN => null, - Payment::DJAMO => null, + Payment::MTN => \Bow\Payment\IvoryCost\MTNMobileMoney\MTNMobileMoneyGateway::class, + Payment::MOOV => \Bow\Payment\IvoryCost\MoovFlooz\MoovFloozGateway::class, + Payment::WAVE => \Bow\Payment\IvoryCost\Wave\WaveGateway::class, + Payment::DJAMO => \Bow\Payment\IvoryCost\Djamo\DjamoGateway::class, ]; /** @@ -101,7 +101,7 @@ private function resolveGateway(string $country, string $provider) if ($provider === null) { throw new \InvalidArgumentException("The payment gateway [{$provider}] is not supported in country [{$country}]."); } - $config = $this->resolveConfig('ivoiry_cost', $provider); + $config = $this->resolveConfig('ivory_coast', $provider); static::$providerGateway = new $provider($config); break; // Other gateways can be added here diff --git a/src/PaymentConfirguration.php b/src/PaymentConfiguration.php similarity index 100% rename from src/PaymentConfirguration.php rename to src/PaymentConfiguration.php diff --git a/src/Support/RateLimiter.php b/src/Support/RateLimiter.php new file mode 100644 index 0000000..96f3360 --- /dev/null +++ b/src/Support/RateLimiter.php @@ -0,0 +1,182 @@ +maxRequests = $maxRequests; + $this->timeWindow = $timeWindow; + $this->cacheFile = $cacheFile ?: sys_get_temp_dir() . '/bow_payment_rate_limit.cache'; + + $this->loadRequests(); + } + + /** + * Check if a request is allowed + * + * @param string $key Unique identifier for the rate limit (e.g., provider name) + * @return bool + */ + public function isAllowed(string $key): bool + { + $this->cleanExpiredRequests($key); + + if (!isset($this->requests[$key])) { + $this->requests[$key] = []; + } + + return count($this->requests[$key]) < $this->maxRequests; + } + + /** + * Record a request + * + * @param string $key + * @return void + * @throws RateLimitException + */ + public function hit(string $key): void + { + if (!$this->isAllowed($key)) { + $retryAfter = $this->getRetryAfter($key); + throw new RateLimitException($retryAfter); + } + + if (!isset($this->requests[$key])) { + $this->requests[$key] = []; + } + + $this->requests[$key][] = time(); + $this->saveRequests(); + } + + /** + * Get seconds until retry is allowed + * + * @param string $key + * @return int + */ + public function getRetryAfter(string $key): int + { + if (!isset($this->requests[$key]) || empty($this->requests[$key])) { + return 0; + } + + $oldestRequest = min($this->requests[$key]); + $retryAfter = ($oldestRequest + $this->timeWindow) - time(); + + return max(0, $retryAfter); + } + + /** + * Remove expired requests from the list + * + * @param string $key + * @return void + */ + private function cleanExpiredRequests(string $key): void + { + if (!isset($this->requests[$key])) { + return; + } + + $now = time(); + $this->requests[$key] = array_filter( + $this->requests[$key], + fn($timestamp) => ($now - $timestamp) < $this->timeWindow + ); + + $this->saveRequests(); + } + + /** + * Load requests from cache file + * + * @return void + */ + private function loadRequests(): void + { + if (file_exists($this->cacheFile)) { + $data = file_get_contents($this->cacheFile); + $this->requests = json_decode($data, true) ?: []; + } + } + + /** + * Save requests to cache file + * + * @return void + */ + private function saveRequests(): void + { + file_put_contents($this->cacheFile, json_encode($this->requests), LOCK_EX); + } + + /** + * Clear all rate limit data for a key + * + * @param string $key + * @return void + */ + public function clear(string $key): void + { + unset($this->requests[$key]); + $this->saveRequests(); + } + + /** + * Clear all rate limit data + * + * @return void + */ + public function clearAll(): void + { + $this->requests = []; + $this->saveRequests(); + } +} diff --git a/src/Support/RetryHandler.php b/src/Support/RetryHandler.php new file mode 100644 index 0000000..ccec584 --- /dev/null +++ b/src/Support/RetryHandler.php @@ -0,0 +1,140 @@ +maxAttempts = $maxAttempts; + $this->retryDelay = $retryDelay; + $this->exponentialBackoff = $exponentialBackoff; + $this->logger = $logger; + } + + /** + * Execute a callable with retry logic + * + * @param callable $callback + * @param array $retryableExceptions List of exception classes to retry on + * @return mixed + * @throws PaymentRequestException + */ + public function execute(callable $callback, array $retryableExceptions = []) + { + $attempt = 0; + $lastException = null; + + while ($attempt < $this->maxAttempts) { + try { + $attempt++; + + if ($this->logger && $attempt > 1) { + $this->logger->info("Retry attempt {$attempt}/{$this->maxAttempts}"); + } + + return $callback(); + } catch (\Exception $e) { + $lastException = $e; + + // Check if this exception is retryable + $shouldRetry = empty($retryableExceptions) || $this->isRetryableException($e, $retryableExceptions); + + if (!$shouldRetry || $attempt >= $this->maxAttempts) { + if ($this->logger) { + $this->logger->error("Request failed after {$attempt} attempts", [ + 'exception' => get_class($e), + 'message' => $e->getMessage(), + ]); + } + throw new PaymentRequestException( + $e->getMessage(), + $e->getCode(), + $e + ); + } + + // Calculate delay + $delay = $this->exponentialBackoff + ? $this->retryDelay * pow(2, $attempt - 1) + : $this->retryDelay; + + if ($this->logger) { + $this->logger->warning("Request failed, retrying in {$delay}ms", [ + 'attempt' => $attempt, + 'exception' => get_class($e), + ]); + } + + // Wait before retry + usleep($delay * 1000); + } + } + + throw new PaymentRequestException( + "Failed after {$this->maxAttempts} attempts: " . $lastException->getMessage() + ); + } + + /** + * Check if an exception is retryable + * + * @param \Exception $exception + * @param array $retryableExceptions + * @return bool + */ + private function isRetryableException(\Exception $exception, array $retryableExceptions): bool + { + foreach ($retryableExceptions as $retryableClass) { + if ($exception instanceof $retryableClass) { + return true; + } + } + return false; + } +} diff --git a/src/Support/TransactionLogger.php b/src/Support/TransactionLogger.php new file mode 100644 index 0000000..4970b16 --- /dev/null +++ b/src/Support/TransactionLogger.php @@ -0,0 +1,174 @@ +logPath = $logPath ?: sys_get_temp_dir() . '/bow_payment_logs'; + $this->enabled = $enabled; + + if ($this->enabled && !file_exists($this->logPath)) { + mkdir($this->logPath, 0755, true); + } + } + + /** + * Log a transaction event + * + * @param string $level + * @param string $message + * @param array $context + * @return void + */ + public function log(string $level, string $message, array $context = []): void + { + if (!$this->enabled) { + return; + } + + $logEntry = [ + 'timestamp' => date('Y-m-d H:i:s'), + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + + $filename = $this->logPath . '/payment_' . date('Y-m-d') . '.log'; + $logLine = json_encode($logEntry) . PHP_EOL; + + file_put_contents($filename, $logLine, FILE_APPEND | LOCK_EX); + } + + /** + * Log an info message + * + * @param string $message + * @param array $context + * @return void + */ + public function info(string $message, array $context = []): void + { + $this->log(self::INFO, $message, $context); + } + + /** + * Log a warning message + * + * @param string $message + * @param array $context + * @return void + */ + public function warning(string $message, array $context = []): void + { + $this->log(self::WARNING, $message, $context); + } + + /** + * Log an error message + * + * @param string $message + * @param array $context + * @return void + */ + public function error(string $message, array $context = []): void + { + $this->log(self::ERROR, $message, $context); + } + + /** + * Log a success message + * + * @param string $message + * @param array $context + * @return void + */ + public function success(string $message, array $context = []): void + { + $this->log(self::SUCCESS, $message, $context); + } + + /** + * Log a payment request + * + * @param string $provider + * @param array $data + * @return void + */ + public function logPaymentRequest(string $provider, array $data): void + { + $this->info("Payment request initiated", [ + 'provider' => $provider, + 'amount' => $data['amount'] ?? null, + 'reference' => $data['reference'] ?? null, + ]); + } + + /** + * Log a payment response + * + * @param string $provider + * @param bool $success + * @param array $data + * @return void + */ + public function logPaymentResponse(string $provider, bool $success, array $data): void + { + $level = $success ? self::SUCCESS : self::ERROR; + $message = $success ? "Payment completed successfully" : "Payment failed"; + + $this->log($level, $message, [ + 'provider' => $provider, + 'data' => $data, + ]); + } + + /** + * Log a transaction verification + * + * @param string $provider + * @param string $transactionId + * @param string $status + * @return void + */ + public function logVerification(string $provider, string $transactionId, string $status): void + { + $this->info("Transaction verification", [ + 'provider' => $provider, + 'transaction_id' => $transactionId, + 'status' => $status, + ]); + } +} diff --git a/src/Webhook/WebhookEvent.php b/src/Webhook/WebhookEvent.php new file mode 100644 index 0000000..fdd2c8b --- /dev/null +++ b/src/Webhook/WebhookEvent.php @@ -0,0 +1,182 @@ +provider = $provider; + $this->payload = $payload; + } + + /** + * Get the provider name + * + * @return string + */ + public function getProvider(): string + { + return $this->provider; + } + + /** + * Get the event payload + * + * @return array + */ + public function getPayload(): array + { + return $this->payload; + } + + /** + * Get event type + * + * @return string|null + */ + public function getType(): ?string + { + return $this->payload['event'] ?? $this->payload['type'] ?? null; + } + + /** + * Get transaction ID + * + * @return string|null + */ + public function getTransactionId(): ?string + { + return $this->payload['transaction_id'] + ?? $this->payload['reference'] + ?? $this->payload['id'] + ?? null; + } + + /** + * Get transaction status + * + * @return string|null + */ + public function getStatus(): ?string + { + return $this->payload['status'] ?? null; + } + + /** + * Get transaction amount + * + * @return float|null + */ + public function getAmount(): ?float + { + $amount = $this->payload['amount'] ?? null; + return $amount ? (float) $amount : null; + } + + /** + * Get currency + * + * @return string|null + */ + public function getCurrency(): ?string + { + return $this->payload['currency'] ?? null; + } + + /** + * Check if event is a payment success + * + * @return bool + */ + public function isPaymentSuccess(): bool + { + $status = strtolower($this->getStatus() ?? ''); + return in_array($status, ['success', 'successful', 'completed', 'paid']); + } + + /** + * Check if event is a payment failure + * + * @return bool + */ + public function isPaymentFailed(): bool + { + $status = strtolower($this->getStatus() ?? ''); + return in_array($status, ['failed', 'rejected', 'declined', 'error']); + } + + /** + * Check if event is pending + * + * @return bool + */ + public function isPaymentPending(): bool + { + $status = strtolower($this->getStatus() ?? ''); + return in_array($status, ['pending', 'processing', 'initiated']); + } + + /** + * Get a specific field from payload + * + * @param string $key + * @param mixed $default + * @return mixed + */ + public function get(string $key, $default = null) + { + return $this->payload[$key] ?? $default; + } + + /** + * Convert event to array + * + * @return array + */ + public function toArray(): array + { + return [ + 'provider' => $this->provider, + 'type' => $this->getType(), + 'transaction_id' => $this->getTransactionId(), + 'status' => $this->getStatus(), + 'amount' => $this->getAmount(), + 'currency' => $this->getCurrency(), + 'payload' => $this->payload, + ]; + } +} diff --git a/src/Webhook/WebhookHandler.php b/src/Webhook/WebhookHandler.php new file mode 100644 index 0000000..1c56644 --- /dev/null +++ b/src/Webhook/WebhookHandler.php @@ -0,0 +1,86 @@ +provider = $provider; + $this->secret = $secret; + } + + /** + * Handle incoming webhook request + * + * @param array $payload + * @param string|null $signature + * @return WebhookEvent + * @throws PaymentException + */ + public function handle(array $payload, ?string $signature = null): WebhookEvent + { + // Validate signature if provided + if ($signature && !$this->validateSignature($payload, $signature)) { + throw new PaymentException('Invalid webhook signature', 401); + } + + return new WebhookEvent($this->provider, $payload); + } + + /** + * Validate webhook signature + * + * @param array $payload + * @param string $signature + * @return bool + */ + public function validateSignature(array $payload, string $signature): bool + { + $expectedSignature = hash_hmac('sha256', json_encode($payload), $this->secret); + + return hash_equals($expectedSignature, $signature); + } + + /** + * Parse raw webhook request + * + * @return array + */ + public static function parseRequest(): array + { + $body = file_get_contents('php://input'); + $data = json_decode($body, true); + + return [ + 'payload' => $data ?? [], + 'signature' => $_SERVER['HTTP_X_SIGNATURE'] ?? $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null, + 'headers' => getallheaders(), + ]; + } +} diff --git a/tests/OrangeMoneyTest.php b/tests/OrangeMoneyTest.php index 4f20d36..11a7e87 100644 --- a/tests/OrangeMoneyTest.php +++ b/tests/OrangeMoneyTest.php @@ -1,9 +1,8 @@ setConstructorArgs([$token, 123456]) ->setMethods(['prepare'])->getMock(); - $payment_status = $this->createMock(OrangeMoney::class); + $payment_status = $this->createMock(OrangeMoneyPayment::class); $payment->method('prepare')->willReturn($payment_status); - $this->assertInstanceOf(OrangeMoney::class, $payment->prepare(500, 'reference', 1)); + $this->assertInstanceOf(OrangeMoneyPayment::class, $payment->prepare(500, 'reference', 1)); } public function testMakePayment() { - $orange = $this->getMockBuilder(OrangeMoney::class) + $orange = $this->getMockBuilder(OrangeMoneyPayment::class) ->disableOriginalConstructor()->setMethods(['pay'])->getMock(); $orange->method('pay')->willReturn(true); diff --git a/tests/PaymentTest.php b/tests/PaymentTest.php new file mode 100644 index 0000000..9406de9 --- /dev/null +++ b/tests/PaymentTest.php @@ -0,0 +1,61 @@ +assertEquals('orange', Payment::ORANGE); + $this->assertEquals('mtn', Payment::MTN); + $this->assertEquals('moov', Payment::MOOV); + $this->assertEquals('wave', Payment::WAVE); + $this->assertEquals('djamo', Payment::DJAMO); + $this->assertEquals('ivory_coast', Payment::CI); + } + + public function testProviderMapping() + { + $providers = Payment::CI_PROVIDER; + + $this->assertArrayHasKey(Payment::ORANGE, $providers); + $this->assertArrayHasKey(Payment::MTN, $providers); + $this->assertArrayHasKey(Payment::MOOV, $providers); + $this->assertArrayHasKey(Payment::WAVE, $providers); + $this->assertArrayHasKey(Payment::DJAMO, $providers); + + $this->assertEquals( + \Bow\Payment\IvoryCost\OrangeMoney\OrangeMoneyGateway::class, + $providers[Payment::ORANGE] + ); + + $this->assertEquals( + \Bow\Payment\IvoryCost\MTNMobileMoney\MTNMobileMoneyGateway::class, + $providers[Payment::MTN] + ); + } + + public function testConfigurePayment() + { + $config = [ + 'default' => [ + 'gateway' => Payment::ORANGE, + 'country' => 'ci', + ], + 'ivory_coast' => [ + 'orange' => [ + 'client_key' => 'test_key', + 'client_secret' => 'test_secret', + ], + ], + ]; + + $payment = Payment::configure($config); + + $this->assertInstanceOf(Payment::class, $payment); + } +} diff --git a/tests/RateLimiterTest.php b/tests/RateLimiterTest.php new file mode 100644 index 0000000..086b089 --- /dev/null +++ b/tests/RateLimiterTest.php @@ -0,0 +1,67 @@ +cacheFile = sys_get_temp_dir() . '/test_rate_limit_' . uniqid() . '.cache'; + $this->limiter = new RateLimiter(5, 60, $this->cacheFile); + } + + protected function tearDown(): void + { + if (file_exists($this->cacheFile)) { + unlink($this->cacheFile); + } + } + + public function testAllowsRequestsWithinLimit() + { + for ($i = 0; $i < 5; $i++) { + $this->assertTrue($this->limiter->isAllowed('test-key')); + $this->limiter->hit('test-key'); + } + } + + public function testThrowsExceptionWhenLimitExceeded() + { + $this->expectException(RateLimitException::class); + + for ($i = 0; $i < 6; $i++) { + $this->limiter->hit('test-key'); + } + } + + public function testDifferentKeysAreIndependent() + { + $this->limiter->hit('key1'); + $this->limiter->hit('key1'); + + $this->assertTrue($this->limiter->isAllowed('key2')); + $this->limiter->hit('key2'); + + $this->assertTrue($this->limiter->isAllowed('key1')); + } + + public function testClearRemovesLimitForKey() + { + for ($i = 0; $i < 5; $i++) { + $this->limiter->hit('test-key'); + } + + $this->assertFalse($this->limiter->isAllowed('test-key')); + + $this->limiter->clear('test-key'); + + $this->assertTrue($this->limiter->isAllowed('test-key')); + } +} diff --git a/tests/RetryHandlerTest.php b/tests/RetryHandlerTest.php new file mode 100644 index 0000000..47a2323 --- /dev/null +++ b/tests/RetryHandlerTest.php @@ -0,0 +1,54 @@ +execute(function() { + return 'success'; + }); + + $this->assertEquals('success', $result); + } + + public function testRetryOnFailure() + { + $attempts = 0; + $handler = new RetryHandler(3, 100, false); + + $this->expectException(PaymentRequestException::class); + + $handler->execute(function() use (&$attempts) { + $attempts++; + throw new \Exception('Test failure'); + }); + + $this->assertEquals(3, $attempts); + } + + public function testSuccessAfterRetry() + { + $attempts = 0; + $handler = new RetryHandler(3, 100, false); + + $result = $handler->execute(function() use (&$attempts) { + $attempts++; + if ($attempts < 2) { + throw new \Exception('Temporary failure'); + } + return 'success'; + }); + + $this->assertEquals('success', $result); + $this->assertEquals(2, $attempts); + } +} diff --git a/tests/TransactionLoggerTest.php b/tests/TransactionLoggerTest.php new file mode 100644 index 0000000..ad8ff8c --- /dev/null +++ b/tests/TransactionLoggerTest.php @@ -0,0 +1,71 @@ +logPath = sys_get_temp_dir() . '/test_payment_logs_' . uniqid(); + $this->logger = new TransactionLogger($this->logPath, true); + } + + protected function tearDown(): void + { + // Clean up log files + if (file_exists($this->logPath)) { + $files = glob($this->logPath . '/*'); + foreach ($files as $file) { + unlink($file); + } + rmdir($this->logPath); + } + } + + public function testLoggerCreatesDirectory() + { + $this->assertDirectoryExists($this->logPath); + } + + public function testLogInfo() + { + $this->logger->info('Test info message', ['key' => 'value']); + + $logFile = $this->logPath . '/payment_' . date('Y-m-d') . '.log'; + $this->assertFileExists($logFile); + + $content = file_get_contents($logFile); + $this->assertStringContainsString('Test info message', $content); + $this->assertStringContainsString('info', $content); + } + + public function testLogPaymentRequest() + { + $this->logger->logPaymentRequest('orange', [ + 'amount' => 1000, + 'reference' => 'TEST-123', + ]); + + $logFile = $this->logPath . '/payment_' . date('Y-m-d') . '.log'; + $content = file_get_contents($logFile); + + $this->assertStringContainsString('Payment request initiated', $content); + $this->assertStringContainsString('orange', $content); + $this->assertStringContainsString('1000', $content); + } + + public function testDisabledLogger() + { + $logger = new TransactionLogger($this->logPath, false); + $logger->info('This should not be logged'); + + $logFile = $this->logPath . '/payment_' . date('Y-m-d') . '.log'; + $this->assertFileDoesNotExist($logFile); + } +} diff --git a/tests/WebhookEventTest.php b/tests/WebhookEventTest.php new file mode 100644 index 0000000..80478d6 --- /dev/null +++ b/tests/WebhookEventTest.php @@ -0,0 +1,91 @@ + 'payment.success', + 'transaction_id' => 'TX-123', + 'status' => 'successful', + 'amount' => 1000, + 'currency' => 'XOF', + ]; + + $event = new WebhookEvent('orange', $payload); + + $this->assertEquals('orange', $event->getProvider()); + $this->assertEquals('payment.success', $event->getType()); + $this->assertEquals('TX-123', $event->getTransactionId()); + $this->assertEquals('successful', $event->getStatus()); + $this->assertEquals(1000.0, $event->getAmount()); + $this->assertEquals('XOF', $event->getCurrency()); + } + + public function testIsPaymentSuccess() + { + $event = new WebhookEvent('orange', ['status' => 'successful']); + $this->assertTrue($event->isPaymentSuccess()); + + $event = new WebhookEvent('orange', ['status' => 'completed']); + $this->assertTrue($event->isPaymentSuccess()); + + $event = new WebhookEvent('orange', ['status' => 'failed']); + $this->assertFalse($event->isPaymentSuccess()); + } + + public function testIsPaymentFailed() + { + $event = new WebhookEvent('orange', ['status' => 'failed']); + $this->assertTrue($event->isPaymentFailed()); + + $event = new WebhookEvent('orange', ['status' => 'rejected']); + $this->assertTrue($event->isPaymentFailed()); + + $event = new WebhookEvent('orange', ['status' => 'successful']); + $this->assertFalse($event->isPaymentFailed()); + } + + public function testIsPaymentPending() + { + $event = new WebhookEvent('orange', ['status' => 'pending']); + $this->assertTrue($event->isPaymentPending()); + + $event = new WebhookEvent('orange', ['status' => 'processing']); + $this->assertTrue($event->isPaymentPending()); + } + + public function testToArray() + { + $payload = [ + 'transaction_id' => 'TX-123', + 'status' => 'successful', + 'amount' => 1000, + ]; + + $event = new WebhookEvent('orange', $payload); + $array = $event->toArray(); + + $this->assertEquals('orange', $array['provider']); + $this->assertEquals('TX-123', $array['transaction_id']); + $this->assertEquals('successful', $array['status']); + $this->assertEquals(1000.0, $array['amount']); + } + + public function testGetCustomField() + { + $event = new WebhookEvent('orange', [ + 'custom_field' => 'custom_value', + 'another_field' => 123, + ]); + + $this->assertEquals('custom_value', $event->get('custom_field')); + $this->assertEquals(123, $event->get('another_field')); + $this->assertEquals('default', $event->get('nonexistent', 'default')); + } +} diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php new file mode 100644 index 0000000..99f4ef8 --- /dev/null +++ b/tests/WebhookHandlerTest.php @@ -0,0 +1,54 @@ +handler = new WebhookHandler('orange', $this->secret); + } + + public function testHandleValidWebhook() + { + $payload = [ + 'event' => 'payment.success', + 'transaction_id' => 'TX-123', + 'status' => 'successful', + 'amount' => 1000, + ]; + + $event = $this->handler->handle($payload); + + $this->assertInstanceOf(WebhookEvent::class, $event); + $this->assertEquals('orange', $event->getProvider()); + $this->assertEquals('TX-123', $event->getTransactionId()); + } + + public function testValidateSignature() + { + $payload = ['test' => 'data']; + $validSignature = hash_hmac('sha256', json_encode($payload), $this->secret); + + $this->assertTrue($this->handler->validateSignature($payload, $validSignature)); + } + + public function testInvalidSignatureThrowsException() + { + $this->expectException(PaymentException::class); + $this->expectExceptionMessage('Invalid webhook signature'); + + $payload = ['test' => 'data']; + $invalidSignature = 'invalid-signature'; + + $this->handler->handle($payload, $invalidSignature); + } +}