Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ To install the `MoneyMan` package, follow these steps:
MONEYMAN_TELEBIRR_TIMEOUT=
MONEYMAN_TELEBIRR_CALLBACK_URL=
MONEYMAN_TELEBIRR_WEB_BASE_URL=

MONEYMAN_BOA_USSD_BASE_URL=
MONEYMAN_BOA_USSD_CLIENT_ID=
MONEYMAN_BOA_USSD_CLIENT_SECRET=
MONEYMAN_BOA_USSD_REFRESH_TOKEN=
MONEYMAN_BOA_USSD_MERCHANT_NAME=
MONEYMAN_BOA_USSD_MERCHANT_ACCOUNT=
MONEYMAN_BOA_USSD_API_KEY=
```

4. **Ready to use!**
Expand All @@ -67,6 +75,7 @@ To install the `MoneyMan` package, follow these steps:
| Chapa | ✅ | ✅ | ✅ |
| Telebirr | ✅ | ✅ | ✅ |
| SantimPay | ✅ | ✅ | ❌ |
| BoaUssd | ✅ | ❌ | ❌ |

## Usage

Expand Down Expand Up @@ -100,6 +109,8 @@ return redirect($response->checkoutUrl);
The initiate response is a DTO that consists of the `status` of of the request, `message` if the provider has one, transactionId, and `checkoutUrl` if the request was successful.

> For telebirr you will need to wrap the `checkoutUrl` provided in an `<a>` tag for it to work or else you will see an error: "**Payment cannot be completed.** The required parameter of the request is empty, or the parameter is incorrectly filled."
>
> For `BoaUssd`, there is no `checkoutUrl` because it is a USSD flow. The provider returns once the user PIN interaction is completed on the phone.

### Verify Payment

Expand Down
9 changes: 9 additions & 0 deletions config/moneyman.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,14 @@
'callback_url' => env('MONEYMAN_TELEBIRR_CALLBACK_URL'),
'web_base_url' => env('MONEYMAN_TELEBIRR_WEB_BASE_URL', 'https://developerportal.ethiotelebirr.et:38443/payment/web/paygate'),
],
'boa_ussd' => [
'base_url' => env('MONEYMAN_BOA_USSD_BASE_URL'),
'client_id' => env('MONEYMAN_BOA_USSD_CLIENT_ID'),
'client_secret' => env('MONEYMAN_BOA_USSD_CLIENT_SECRET'),
'refresh_token' => env('MONEYMAN_BOA_USSD_REFRESH_TOKEN'),
'merchant_name' => env('MONEYMAN_BOA_USSD_MERCHANT_NAME'),
'merchant_account' => env('MONEYMAN_BOA_USSD_MERCHANT_ACCOUNT'),
'api_key' => env('MONEYMAN_BOA_USSD_API_KEY'),
],
],
];
2 changes: 2 additions & 0 deletions src/Enums/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ enum Provider: string
case Chapa = 'chapa';

case SantimPay = 'santimpay';

case BoaUssd = 'boa_ussd';
}
6 changes: 6 additions & 0 deletions src/MoneyManManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Vptrading\MoneyMan;

use Vptrading\MoneyMan\Enums\Provider as ProviderEnum;
use Vptrading\MoneyMan\Providers\BoaUssd\BoaUssd;
use Vptrading\MoneyMan\Providers\Chapa\Chapa;
use Vptrading\MoneyMan\Providers\Provider;
use Vptrading\MoneyMan\Providers\SantimPay\SantimPay;
Expand Down Expand Up @@ -48,4 +49,9 @@ protected function createSantimPayProvider(): Provider
{
return new SantimPay;
}

protected function createBoa_ussdProvider(): Provider
{
return new BoaUssd;
}
}
135 changes: 135 additions & 0 deletions src/Providers/BoaUssd/BoaUssd.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

namespace Vptrading\MoneyMan\Providers\BoaUssd;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use LogicException;
use Money\Money;
use Override;
use Vptrading\MoneyMan\Contracts\Responses\PaymentInitiateResponse;
use Vptrading\MoneyMan\Contracts\Responses\PaymentRefundResponse;
use Vptrading\MoneyMan\Contracts\Responses\PaymentVerifyResponse;
use Vptrading\MoneyMan\Providers\BoaUssd\Factories\PaymentInitiateFactory;
use Vptrading\MoneyMan\Providers\Provider;
use Vptrading\MoneyMan\ValueObjects\User;

class BoaUssd extends Provider
{
private const ACCESS_TOKEN_CACHE_KEY = 'boa_ussd.access_token';

private const REFRESH_TOKEN_CACHE_KEY = 'boa_ussd.refresh_token';

private const TOKEN_LOCK_KEY = 'boa_ussd.token_refresh_lock';

public function __construct()
{
parent::__construct();

foreach (config('moneyman.providers.boa_ussd') as $key => $value) {
if (empty($value)) {
$formattedKey = ucwords(implode(' ', explode('_', $key)));
throw new \InvalidArgumentException("BOA USSD $formattedKey is not set.");
}
}
}

public function initiate(Money $money, User $user, string $returnUrl, ?string $reason = null, ?array $parameters = []): PaymentInitiateResponse
{
$response = Http::withToken($this->getAccessToken())
->withHeader('x-api-key', config('moneyman.providers.boa_ussd.api_key'))
->post($this->baseUrl().'/ussd/push', [
'ID' => config('moneyman.providers.boa_ussd.client_id'),
'phoneNumber' => $user->phoneNumber,
'amount' => $this->formatter->format($money),
'merchantName' => config('moneyman.providers.boa_ussd.merchant_name'),
'merchantAccount' => config('moneyman.providers.boa_ussd.merchant_account'),
'billNumber' => config('moneyman.ref_prefix').str()->random(10),
]);

return PaymentInitiateFactory::fromApiResponse(array_merge($response->json(), [
'_http_successful' => $response->successful(),
]));
}

#[Override]
public function verify(string $transactionId): PaymentVerifyResponse
{
throw new LogicException("BOA USSD doesn't provide verify functionality yet.");
}

#[Override]
public function refund(string $transactionId, ?Money $amount = null, ?string $reason = null): PaymentRefundResponse
{
throw new LogicException("BOA USSD doesn't provide refund functionality yet.");
}

private function getAccessToken(): string
{
$cachedToken = Cache::get(self::ACCESS_TOKEN_CACHE_KEY);

if (is_string($cachedToken) && $cachedToken !== '') {
return $cachedToken;
}

return Cache::lock(self::TOKEN_LOCK_KEY, 10)->block(5, function (): string {
$tokenAfterLock = Cache::get(self::ACCESS_TOKEN_CACHE_KEY);

if (is_string($tokenAfterLock) && $tokenAfterLock !== '') {
return $tokenAfterLock;
}

return $this->refreshTokens();
});
}

private function refreshTokens(): string
{
$response = Http::post($this->baseUrl().'/ussd/push/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $this->resolveRefreshToken(),
'client_secret' => config('moneyman.providers.boa_ussd.client_secret'),
'client_id' => config('moneyman.providers.boa_ussd.client_id'),
]);

if (! $response->successful()) {
throw new \RuntimeException($response->json('error_description') ?? 'Failed to refresh BOA USSD token.');
}

$accessToken = (string) $response->json('access_token');
$refreshToken = (string) $response->json('refresh_token');

if ($accessToken === '' || $refreshToken === '') {
throw new \RuntimeException('BOA USSD token response is missing access_token or refresh_token.');
}

Cache::put(self::ACCESS_TOKEN_CACHE_KEY, $accessToken, now()->addMinutes(25));
Cache::put(self::REFRESH_TOKEN_CACHE_KEY, $refreshToken, now()->addDays(7));

return $accessToken;
}

private function resolveRefreshToken(): string
{
$cachedToken = Cache::get(self::REFRESH_TOKEN_CACHE_KEY);

if (is_string($cachedToken) && $cachedToken !== '') {
return $cachedToken;
}

$seedToken = (string) config('moneyman.providers.boa_ussd.refresh_token');

if ($seedToken === '') {
throw new \RuntimeException('BOA USSD refresh token is not configured.');
}

return $seedToken;
}

private function baseUrl(): string
{
return trim((string) config('moneyman.providers.boa_ussd.base_url'), '/');
}
}
43 changes: 43 additions & 0 deletions src/Providers/BoaUssd/Dtos/PaymentInitiateResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Vptrading\MoneyMan\Providers\BoaUssd\Dtos;

use Override;
use Vptrading\MoneyMan\Contracts\Responses\PaymentInitiateResponse as PaymentInitiateResponseContract;

class PaymentInitiateResponse implements PaymentInitiateResponseContract
{
public function __construct(
public string $status,
public ?string $message,
public ?string $transactionId = null,
public ?string $checkoutUrl = null,
public array $validationErrors = []
) {}

#[Override]
public function isSuccessful(): bool
{
return $this->status === 'success';
}

#[Override]
public function getCheckoutUrl(): ?string
{
throw new \LogicException('USSD Payment does not have checkout url');
}

#[Override]
public function getMessage(): ?string
{
return $this->message;
}

#[Override]
public function getTransactionId(): ?string
{
return $this->transactionId;
}
}
30 changes: 30 additions & 0 deletions src/Providers/BoaUssd/Factories/PaymentInitiateFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Vptrading\MoneyMan\Providers\BoaUssd\Factories;

use Vptrading\MoneyMan\Contracts\Factories\ProviderResponse;
use Vptrading\MoneyMan\Providers\BoaUssd\Dtos\PaymentInitiateResponse;

class PaymentInitiateFactory implements ProviderResponse
{
public static function fromApiResponse(array $response): PaymentInitiateResponse
{
$successful = $response['_http_successful'] ?? false;

if (! $successful) {
return new PaymentInitiateResponse(
status: 'error',
message: $response['pushUSSDResult']['ResultDesc'] ?? 'Unable to initiate BOA USSD payment.',
transactionId: $response['pushUSSDResult']['billNumber'] ?? null,
);
}

return new PaymentInitiateResponse(
status: 'success',
message: $response['pushUSSDResult']['paymentStatus'] ?? null,
transactionId: $response['pushUSSDResult']['billNumber'] ?? null,
);
}
}
Loading
Loading