├── .github ├── FUNDING.yml └── workflows │ └── php.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── composer.json ├── composer.lock ├── config └── selcom.php ├── database └── migrations │ └── 2021_13_09_000000_create_selcom_payments_table.php ├── phpunit.xml ├── resources └── views │ ├── cancel.blade.php │ └── redirect.blade.php ├── routes └── web.php ├── src ├── Events │ └── CheckoutWebhookReceived.php ├── Exceptions │ ├── ConfigurationUnavailableException.php │ ├── InvalidDataException.php │ └── MissingDataException.php ├── Facades │ └── Selcom.php ├── Http │ └── Controllers │ │ └── CheckoutCallbackController.php ├── Selcom.php ├── SelcomBaseServiceProvider.php └── Traits │ ├── HandlesCheckout.php │ └── ValidatesData.php └── tests ├── Feature ├── Checkout │ └── CheckoutTest.php └── Facades │ └── SelcomFacadeTest.php ├── TestCase.php └── stubs ├── card-payment-response.json ├── create-order-response.json ├── ok-response-data.json ├── stored-cards-response.json └── wallet-payment-response.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | patreon: bryceandy -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | phpunit-tests: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install Dependencies 17 | run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist 18 | - name: Execute tests via PHPUnit 19 | run: vendor/bin/phpunit 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .idea 3 | .phpunit.result.cache 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v0.0.7](https://github.com/bryceandy/laravel-selcom/compare/v0.0.6...v0.0.7) - June 23, 2022 4 | * Bump `guzzlehttp/guzzle` to fix change in port should be considered a change in origin 5 | 6 | ## [v0.0.6](https://github.com/bryceandy/laravel-selcom/compare/v0.0.5...v0.0.6) - June 18, 2022 7 | * Bump `guzzlehttp/psr7` to fix cross domain cookie leakage 8 | * Bump `guzzlehttp/psr7` to fix failure to strip authorization header on HTTP downgrade 9 | * Bump `guzzlehttp/psr7` to fix failure to strip the cookie header on change in host or HTTP downgrade 10 | 11 | ## [v0.0.5](https://github.com/bryceandy/laravel-selcom/compare/v0.0.4...v0.0.5) - April 28, 2022 12 | * Bump `guzzlehttp/psr7` to fix security issue Improper Input Validation 13 | 14 | ## [v0.0.4](https://github.com/bryceandy/laravel-selcom/compare/v0.0.3...v0.0.4) - December 25, 2021 15 | * Return the payment gateway URL as data instead of redirecting to the URL for JSON requests 16 | 17 | ## [v0.0.3](https://github.com/bryceandy/laravel-selcom/compare/v0.0.2...v0.0.3) - October 28, 2021 18 | * Add support for PHP 8 19 | 20 | ## [v0.0.2](https://github.com/bryceandy/laravel-selcom/compare/v0.0.1...v0.0.2) - September 15, 2021 21 | * Add `selcom_transaction_id` to the payments table 22 | 23 | ## v0.0.1 - September 15, 2021 24 | * Checkout API 25 | * Initial release 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | # Selcom package for Laravel apps 4 | 5 | [![Actions Status](https://github.com/bryceandy/laravel-selcom/workflows/Tests/badge.svg)](https://github.com/bryceandy/laravel-selcom/actions) 6 | Total Downloads 7 | Latest Stable Version 8 | License 9 | 10 | This package enables Laravel developers to integrate their websites/APIs with all Selcom API services 11 | 12 | ## Installation 13 | 14 | Pre-installation requirements 15 | 16 | * Supports Laravel projects starting version 8.* 17 | * Minimum PHP version is 7.4 18 | * Your server must have the cURL PHP extension (ext-curl) installed 19 | 20 | Then proceed to install: 21 | 22 | ``` 23 | composer require bryceandy/laravel-selcom 24 | ``` 25 | 26 | ## Configuration 27 | 28 | To access Selcom's APIs, you will need to provide the package with access to your Selcom vendorID, API Key and Secret Key. 29 | 30 | After obtaining the three credentials from Selcom support, add their values in the `.env` variables: 31 | 32 | ```dotenv 33 | SELCOM_VENDOR_ID=123456 34 | SELCOM_API_KEY=yourApiKey 35 | SELCOM_API_SECRET=yourSecretKey 36 | 37 | SELCOM_IS_LIVE=false 38 | ``` 39 | 40 | Note that when starting you will be provided with test credentials. 41 | 42 | When you change to live credentials don't forget to change `SELCOM_IS_LIVE` to `true`. 43 | 44 | We are going to update more configuration settings as we move along, but feel free to publish the config to fully customize it. 45 | 46 | ``` 47 | php artisan vendor:publish --tag=selcom-config 48 | ``` 49 | 50 | Run the migration command to create a table that stores Selcom payments: 51 | 52 | ``` 53 | php artisan migrate 54 | ``` 55 | 56 | ## Checkout API 57 | 58 | Checkout is the simplest Selcom API we can start processing payments with. 59 | 60 | ### Checkout payments using USSD 61 | 62 | This API automatically pulls your user's USSD payment menu directly after being called. 63 | 64 | **Note**: As of now, this is only applicable to AirtelMoney and TigoPesa customers. 65 | 66 | ```php 67 | use Bryceandy\Selcom\Facades\Selcom; 68 | 69 | Selcom::checkout([ 70 | 'name' => "Buyer's full name", 71 | 'email' => "Buyer's email", 72 | 'phone' => "Buyer's msisdn, for example 255756334000", 73 | 'amount' => "Amount to be paid", 74 | 'transaction_id' => "Unique transaction id", 75 | 'no_redirection' => true, 76 | // Optional fields 77 | 'currency' => 'Default is TZS', 78 | 'items' => 'Number of items purchased, default is 1', 79 | 'payment_phone' => 'The number that will make the USSD transactions, if not specified it will use the phone value', 80 | ]); 81 | ``` 82 | 83 | Other networks may use USSD checkout manually with tokens as shown with other checkout options below. 84 | 85 | ### Checkout to the payments page (without cards) 86 | 87 | The payment page contains payment options such as QR code, Masterpass, USSD wallet pull, mobile money payment with tokens. 88 | 89 | To redirect to this page, we will use the previous example, but **return** without the `no_redirection` option or assign it to `false`: 90 | 91 | ```php 92 | use Bryceandy\Selcom\Facades\Selcom; 93 | 94 | return Selcom::checkout([ 95 | 'name' => "Buyer's full name", 96 | 'email' => "Buyer's email", 97 | 'phone' => "Buyer's msisdn, for example 255756334000", 98 | 'amount' => "Amount to be paid", 99 | 'transaction_id' => "Unique transaction id", 100 | ]); 101 | ``` 102 | 103 | ### Checkout to the payments page (with cards) 104 | 105 | To use the cards on the payment page, return the following request: 106 | 107 | ```php 108 | use Bryceandy\Selcom\Facades\Selcom; 109 | 110 | return Selcom::cardCheckout([ 111 | 'name' => "Buyer's full name", 112 | 'email' => "Buyer's email", 113 | 'phone' => "Buyer's msisdn, for example 255756334000", 114 | 'amount' => "Amount to be paid", 115 | 'transaction_id' => "Unique transaction id", 116 | 'address' => "Your buyer's address", 117 | 'postcode' => "Your buyer's postcode", 118 | // Optional fields 119 | 'user_id' => "Buyer's user ID in your system", 120 | 'buyer_uuid' => $buyerUuid, // Important if the user has to see their saved cards. 121 | // See the last checkout section on how to fetch a buyer's UUID 122 | 'country_code' => "Your buyer's ISO country code: Default is TZ", 123 | 'state' => "Your buyer's state: Default is Dar Es Salaam", 124 | 'city' => "Your buyer's city: Default is Dar Es Salaam", 125 | 'billing_phone' => "Your buyer's billing phone number: forexample 255756334000", 126 | 'currency' => 'Default is TZS', 127 | 'items' => 'Number of items purchased, default is 1', 128 | ]); 129 | ``` 130 | 131 | Optionally, you may specify using the `.env` file the following: 132 | 133 | - The page where your users will be redirected once they complete a payment: 134 | 135 | ```dotenv 136 | SELCOM_REDIRECT_URL=https://mysite.com/selcom/redirect 137 | ``` 138 | 139 | - The page where your users will be taken when they cancel the payment process: 140 | 141 | ```dotenv 142 | SELCOM_CANCEL_URL=https://mysite.com/selcom/cancel 143 | ``` 144 | 145 | If you feel lazy, this package already ships with these pages for you. And if you want to customize them, run: 146 | 147 | ``` 148 | php artisan vendor:publish --tag=selcom-views 149 | ``` 150 | 151 |

152 | 153 | 154 |

155 | 156 | - Also, you can assign a prefix for the package. This will be applied to the routes and order IDs 157 | 158 | ```dotenv 159 | SELCOM_PREFIX=SHOP 160 | ``` 161 | 162 | #### Customizing the payment page theme 163 | 164 | The configuration contains a `colors` field which specifies the theme of your payment page. 165 | 166 | To customize the colors, add the color values in the `.env` file: 167 | 168 | ```dotenv 169 | SELCOM_HEADER_COLOR="#FG345O" 170 | SELCOM_LINK_COLOR="#000000" 171 | SELCOM_BUTTON_COLOR="#E244FF" 172 | ``` 173 | 174 | For JSON requests (API applications), this type of checkout to the payments page will return data with 175 | `payment_gateway_url` instead of redirecting to that page: 176 | 177 | ```json 178 | { 179 | "payment_gateway_url": "https://example.selcommobile-url.com" 180 | } 181 | ``` 182 | 183 | ### Checkout payments with cards (without navigating to the payment page) 184 | 185 | To use a card without navigating to the payment page, you need to have already created a card for the paying user by navigating to the payment page. 186 | 187 | This is very useful for recurring or on-demand card payments. The data is the same as the previous card checkout, except we are adding `no_redirection`, `user_id` & `buyer_uuid`: 188 | 189 | ```php 190 | use Bryceandy\Selcom\Facades\Selcom; 191 | 192 | Selcom::cardCheckout([ 193 | 'name' => "Buyer's full name", 194 | 'email' => "Buyer's email", 195 | 'phone' => "Buyer's msisdn, for example 255756334000", 196 | 'amount' => "Amount to be paid", 197 | 'transaction_id' => "Unique transaction id", 198 | 'no_redirection' => true, 199 | 'user_id' => "Buyer's user ID in your system", 200 | 'buyer_uuid' => $buyerUuid, // See instructions below on how to obtain this value 201 | 'address' => "Your buyer's address", 202 | 'postcode' => "Your buyer's postcode", 203 | // Optional fields 204 | 'country_code' => "Your buyer's ISO country code: Default is TZ", 205 | 'state' => "Your buyer's state: Default is Dar Es Salaam", 206 | 'city' => "Your buyer's city: Default is Dar Es Salaam", 207 | 'billing_phone' => "Your buyer's billing phone number: forexample 255756334000", 208 | 'currency' => 'Default is TZS', 209 | 'items' => 'Number of items purchased, default is 1', 210 | ]); 211 | ``` 212 | 213 | This method will fetch 3 saved cards of the user and try all of them until a payment is successful or all fail. 214 | 215 | #### Obtaining the buyer's UUID 216 | 217 | If this user has visited the payment page before to make a payment, then their uuid is already in the database. 218 | 219 | ```php 220 | use Illuminate\Support\Facades\DB; 221 | 222 | $buyerUuid = DB::table('selcom_payments') 223 | ->where([ 224 | ['user_id', '=' auth()->id()], 225 | ['gateway_buyer_uuid', '<>', null], 226 | ]) 227 | ->value('gateway_buyer_uuid'); 228 | ``` 229 | 230 | ### Listing a user's stored cards 231 | 232 | To fetch the user's stored cards could be useful to know if a user has cards, or if there is a need to delete. 233 | 234 | You will require a user's ID and `buyer_uuid`: 235 | 236 | ```php 237 | use Bryceandy\Selcom\Facades\Selcom; 238 | 239 | Selcom::fetchCards($userId, $gatewayBuyerUuid); 240 | ``` 241 | 242 | ### Deleting a user's stored card 243 | 244 | To delete a user's stored card you need a `buyer_uuid` and card ID obtained from `fetchCards` request above. 245 | 246 | ```php 247 | use Bryceandy\Selcom\Facades\Selcom; 248 | 249 | Selcom::deleteCard($cardId, $gatewayBuyerUuid); 250 | ``` 251 | 252 | ### Checkout webhook/callback 253 | 254 | The package comes with an implementation of the payment webhook. 255 | 256 | When Selcom sends the payment status to your site, the data in the `selcom_payments` table will be updated and an event `Bryceandy\Selcom\Events\CheckoutWebhookReceived` will be dispatched. 257 | 258 | You can create a listener for the event: 259 | 260 | ```php 261 | class EventServiceProvider extends ServiceProvider 262 | { 263 | protected $listen = [ 264 | \Bryceandy\Selcom\Events\CheckoutWebhookReceived::class => [ 265 | \App\Listeners\ProcessWebhook::class, 266 | ], 267 | ]; 268 | } 269 | ``` 270 | 271 | Then in your listener `App\Listeners\ProcessWebhook`, you can do anything with the order ID: 272 | 273 | ```php 274 | orderId; 293 | 294 | // Fetch updated record in the database, and do what you need with it 295 | $status = DB::table('selcom_payments') 296 | ->where('order_id', $orderId) 297 | ->value('payment_status'); 298 | 299 | if ($status === 'PENDING') { 300 | Selcom::orderStatus($orderId); // Or dispatch a job minutes later to query order status 301 | } 302 | } 303 | } 304 | ``` 305 | 306 | ### Check order status 307 | 308 | To query order statuses to Selcom, simply run: 309 | 310 | ```php 311 | use Bryceandy\Selcom\Facades\Selcom; 312 | use Illuminate\Support\Facades\DB; 313 | 314 | $order = Selcom::orderStatus($orderId); 315 | ``` 316 | 317 | Once you have obtained the order data, you can use it as you wish. The example below updates the payment in the database: 318 | 319 | ```php 320 | DB::table('selcom_payments')->where('order_id', $orderId) 321 | ->update(array_merge( 322 | 'payment_status' => $order['payment_status'], 323 | ($order['payment_status'] === 'COMPLETED' 324 | ? [ 325 | 'selcom_transaction_id' => $order['transid'], 326 | 'channel' => $order['channel'], 327 | 'reference' => $order['reference'], 328 | 'msisdn' => $order['msisdn'], 329 | ] 330 | : [] 331 | ) 332 | )); 333 | ``` 334 | 335 | ### List orders 336 | 337 | To list all orders made to Selcom, simply indicate `from_date` and `to_date`: 338 | 339 | ```php 340 | use Bryceandy\Selcom\Facades\Selcom; 341 | 342 | $fromDate = '2021-02-16'; 343 | $toDate = '2021-12-25'; 344 | 345 | Selcom::listOrders($fromDate, $toDate); 346 | ``` 347 | 348 | ### Cancel order 349 | 350 | To cancel a Selcom order, simply run: 351 | 352 | ```php 353 | use Bryceandy\Selcom\Facades\Selcom; 354 | 355 | Selcom::cancelOrder($orderId); 356 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bryceandy/laravel-selcom", 3 | "description": "Laravel package that integrates with Selcom APIs (Utility Payments, Wallet Cashin, Agent Cashout, C2B, Qwiksend, VCN, Checkout & International Money Transfer", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "bryceandy", 8 | "email": "hello@bryceandy.com" 9 | } 10 | ], 11 | "minimum-stability": "stable", 12 | "prefer-stable": true, 13 | "require": { 14 | "php": "^7.4|^8.0", 15 | "ext-curl": "*", 16 | "guzzlehttp/guzzle": "^7.3" 17 | }, 18 | "require-dev": { 19 | "orchestra/testbench": "^6.19", 20 | "ext-json": "*" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Bryceandy\\Selcom\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Bryceandy\\Selcom\\Tests\\": "tests/" 30 | } 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "Bryceandy\\Selcom\\SelcomBaseServiceProvider" 36 | ] 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /config/selcom.php: -------------------------------------------------------------------------------- 1 | env('SELCOM_VENDOR_ID'), 13 | 14 | /* 15 | |-------------------------------------------------------------------------- 16 | | API Key 17 | |-------------------------------------------------------------------------- 18 | | 19 | | Merchant API key 20 | */ 21 | 'key' => env('SELCOM_API_KEY'), 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | API Secret 26 | |-------------------------------------------------------------------------- 27 | | 28 | | Merchant API secret 29 | */ 30 | 'secret' => env('SELCOM_API_SECRET'), 31 | 32 | /* 33 | |-------------------------------------------------------------------------- 34 | | Selcom live status 35 | |-------------------------------------------------------------------------- 36 | | 37 | | This determines if you are using Selcom in live mode. 38 | | The credentials would be different in every stage. 39 | | 40 | | SELCOM_API_KEY and SELCOM_API_SECRET should be 41 | | different when changing between live & test. 42 | */ 43 | 'live' => env('SELCOM_IS_LIVE', false), 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | Selcom prefix 48 | |-------------------------------------------------------------------------- 49 | | 50 | | This prefix will be used for routes and on Selcom order IDs. 51 | */ 52 | 'prefix' => env('SELCOM_PREFIX', 'selcom'), 53 | 54 | /* 55 | |-------------------------------------------------------------------------- 56 | | Redirect URL 57 | |-------------------------------------------------------------------------- 58 | | 59 | | The URL where your users will be taken to after a payment is complete. 60 | | Eg: https://www.myshop.co.tz/checkout/redirect 61 | */ 62 | 'redirect_url' => env('SELCOM_REDIRECT_URL'), 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | Cancel URL 67 | |-------------------------------------------------------------------------- 68 | | 69 | | The URL where your users will be taken to when they cancel the payment. 70 | | Eg: https://www.myshop.co.tz/checkout/cancel 71 | */ 72 | 'cancel_url' => env('SELCOM_CANCEL_URL'), 73 | 74 | /* 75 | |-------------------------------------------------------------------------- 76 | | Payment Gateway Colors 77 | |-------------------------------------------------------------------------- 78 | | 79 | | Colors for your payment gateway page. 80 | */ 81 | 'colors' => [ 82 | 'header' => env('SELCOM_HEADER_COLOR', '#FF0012'), 83 | 'link' => env('SELCOM_LINK_COLOR', '#FF0012'), 84 | 'button' => env('SELCOM_BUTTON_COLOR', '#FF0012'), 85 | ], 86 | 87 | /* 88 | |-------------------------------------------------------------------------- 89 | | Payment Expiry 90 | |-------------------------------------------------------------------------- 91 | | 92 | | Time in minutes before the payment gateway page expires. 93 | */ 94 | 'expiry' => env('SELCOM_PAYMENT_EXPIRY', 60) 95 | ]; 96 | -------------------------------------------------------------------------------- /database/migrations/2021_13_09_000000_create_selcom_payments_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->integer('amount'); 19 | $table->string('order_id')->unique(); 20 | $table->string('transid')->unique(); 21 | $table->string('selcom_transaction_id')->nullable(); 22 | $table->string('user_id')->nullable(); 23 | $table->string('gateway_buyer_uuid')->nullable(); 24 | $table->string('payment_status')->nullable(); 25 | $table->string('reference')->nullable(); 26 | $table->string('msisdn')->nullable(); 27 | $table->string('channel')->nullable(); 28 | $table->timestamps(); 29 | }); 30 | } 31 | 32 | /** 33 | * Reverse the migrations. 34 | * 35 | * @return void 36 | */ 37 | public function down() 38 | { 39 | Schema::dropIfExists('selcom_payments'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | ./tests/Feature 11 | 12 | 13 | 14 | 15 | ./src 16 | 17 | 18 | -------------------------------------------------------------------------------- /resources/views/cancel.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | Selcom | Payment Cancelled 10 | 11 | 12 |
13 |
14 |

15 | {{ config('app.name') }} 16 |

17 |
18 |

19 | Your payment is incomplete. Would you like to go back and try again or return home? 20 |

21 | 43 |
44 |
45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /resources/views/redirect.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | Selcom | Payment Completed 10 | 11 | 12 |
13 |
14 |

15 | {{ config('app.name') }} 16 |

17 |
18 |

19 | Thank you for completing your purchase, you will be notified when there will be any changes on 20 | your order, or if the status changes. 21 |

22 | 37 |
38 |
39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | name('selcom.checkout-callback');; 8 | 9 | Route::view('redirect', 'selcom::redirect')->name('selcom.redirect'); 10 | 11 | Route::view('cancel', 'selcom::cancel')->name('selcom.cancel'); 12 | -------------------------------------------------------------------------------- /src/Events/CheckoutWebhookReceived.php: -------------------------------------------------------------------------------- 1 | orderId = $orderId; 20 | } 21 | } -------------------------------------------------------------------------------- /src/Exceptions/ConfigurationUnavailableException.php: -------------------------------------------------------------------------------- 1 | vendor = config('selcom.vendor'); 27 | 28 | $this->apiKey = config('selcom.key'); 29 | 30 | $this->apiSecret = config('selcom.secret'); 31 | 32 | $subdomain = config('selcom.live') ? 'apigw' : 'apigwtest'; 33 | 34 | $this->apiUrl = "https://$subdomain.selcommobile.com/v1/"; 35 | } 36 | 37 | public function prefix() 38 | { 39 | return config('selcom.prefix'); 40 | } 41 | 42 | public function redirectUrl() 43 | { 44 | return config('selcom.redirect_url') ?? route('selcom.redirect'); 45 | } 46 | 47 | public function cancelUrl() 48 | { 49 | return config('selcom.cancel_url') ?? route('selcom.cancel'); 50 | } 51 | 52 | public function paymentGatewayColors() 53 | { 54 | return config('selcom.colors'); 55 | } 56 | 57 | public function paymentExpiry() 58 | { 59 | return config('selcom.expiry'); 60 | } 61 | 62 | /** 63 | * @throws MissingDataException 64 | */ 65 | private function validateConfig() 66 | { 67 | if (! config('selcom.vendor') || ! config('selcom.key') || ! config('selcom.secret')) { 68 | throw new MissingDataException( 69 | 'Your Selcom credentials can not be empty!' 70 | ); 71 | } 72 | } 73 | 74 | public function makeRequest(string $uri, string $method, array $data = []): Response 75 | { 76 | $fullPath = $this->apiUrl . $uri; 77 | 78 | return Http::withHeaders($this->getHeaders($data)) 79 | ->{strtolower($method)}($fullPath, $data); 80 | } 81 | 82 | private function getHeaders($data): array 83 | { 84 | $this->validateConfig(); 85 | 86 | date_default_timezone_set('Africa/Dar_es_Salaam'); 87 | 88 | $authorization = base64_encode($this->apiKey); 89 | $signedFields = implode(',', array_keys($data)); 90 | $timestamp = date('c'); 91 | $digest = $this->getDigest($data, $timestamp); 92 | 93 | return [ 94 | 'Content-type' => 'application/json;charset=\"utf-8\"', 95 | 'Accept' => 'application/json', 96 | 'Authorization' => "SELCOM $authorization", 97 | 'Digest-Method' => 'HS256', 98 | 'Digest' => $digest, 99 | 'Signed-Fields' => $signedFields, 100 | 'Cache-Control' => 'no-cache', 101 | 'Timestamp' => $timestamp, 102 | ]; 103 | } 104 | 105 | private function getDigest($data, $timestamp): string 106 | { 107 | $this->validateConfig(); 108 | 109 | $signData = "timestamp=$timestamp"; 110 | 111 | if (count($data)) { 112 | foreach ($data as $key => $value) { 113 | $signData .= "&$key=$value"; 114 | } 115 | } 116 | 117 | return base64_encode(hash_hmac('sha256', $signData, $this->apiSecret, true)); 118 | } 119 | } -------------------------------------------------------------------------------- /src/SelcomBaseServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 14 | $this->publishes([ 15 | __DIR__ . '/../config/selcom.php' => config_path('selcom.php'), 16 | ], 'selcom-config'); 17 | 18 | $this->publishes([ 19 | __DIR__ . '/../resources/views' => resource_path('views/vendor/selcom'), 20 | ], 'selcom-views'); 21 | } 22 | 23 | $this->mergeConfigFrom( 24 | __DIR__ . '/../config/selcom.php', 'selcom' 25 | ); 26 | 27 | $this->loadAssets(); 28 | } 29 | 30 | public function register() 31 | { 32 | $this->registerFacades(); 33 | 34 | $this->registerRoutes(); 35 | } 36 | 37 | private function loadAssets() 38 | { 39 | $this->loadViewsFrom(__DIR__ . '/../resources/views', 'selcom'); 40 | 41 | $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); 42 | } 43 | 44 | private function registerFacades() 45 | { 46 | $this->app->singleton('Selcom', fn($app) => new \Bryceandy\Selcom\Selcom); 47 | } 48 | 49 | private function registerRoutes() 50 | { 51 | $prefix = Selcom::prefix(); 52 | 53 | Route::group( 54 | compact('prefix'), 55 | fn() => $this->loadRoutesFrom(__DIR__ . '/../routes/web.php') 56 | ); 57 | } 58 | } -------------------------------------------------------------------------------- /src/Traits/HandlesCheckout.php: -------------------------------------------------------------------------------- 1 | validateCheckoutData($data); 15 | 16 | $orderId = $this->generateOrderId(); 17 | 18 | $orderRequest = $this->makeRequest( 19 | 'checkout/create-order-minimal', 20 | 'POST', 21 | $this->getMinimalOrderData($data, $orderId) 22 | ); 23 | 24 | return $this->handleOrderResponse($orderRequest, $data, $orderId); 25 | } 26 | 27 | public function cardCheckout(array $data) 28 | { 29 | $this->validateCardCheckoutData($data); 30 | 31 | $orderId = $this->generateOrderId(); 32 | 33 | $orderRequest = $this->makeRequest( 34 | 'checkout/create-order', 35 | 'POST', 36 | array_merge( 37 | $this->getMinimalOrderData($data, $orderId), 38 | $this->getCardCheckoutExtraData($data), 39 | (($data['user_id'] ?? false) ? ['buyer_userid' => $data['user_id']] : []), 40 | (($data['buyer_uuid'] ?? false) ? ['gateway_buyer_uuid' => $data['buyer_uuid']] : []) 41 | ) 42 | ); 43 | 44 | return $this->handleOrderResponse($orderRequest, $data, $orderId, true); 45 | } 46 | 47 | private function generateOrderId(): string 48 | { 49 | return (string) Str::of($this->prefix())->snake('')->upper() 50 | . now()->timestamp 51 | . rand(1, 9999); 52 | } 53 | 54 | private function getMinimalOrderData(array $data, string $orderId): array 55 | { 56 | return [ 57 | 'vendor' => $this->vendor, 58 | 'order_id' => $orderId, 59 | 'buyer_email' => $data['email'], 60 | 'buyer_name' => $data['name'], 61 | 'buyer_phone' => $data['phone'], 62 | 'amount' => (int) $data['amount'], 63 | 'currency' => $data['currency'] ?? 'TZS', 64 | 'redirect_url' => base64_encode($this->redirectUrl()), 65 | 'cancel_url' => base64_encode($this->cancelUrl()), 66 | 'webhook' => base64_encode(route('selcom.checkout-callback')), 67 | 'no_of_items' => (int) ($data['items'] ?? 1), 68 | 'expiry' => $this->paymentExpiry(), 69 | 'header_colour' => $this->paymentGatewayColors()['header'], 70 | 'link_colour' => $this->paymentGatewayColors()['link'], 71 | 'button_colour' => $this->paymentGatewayColors()['button'], 72 | ]; 73 | } 74 | 75 | private function getCardCheckoutExtraData(array $data): array 76 | { 77 | return [ 78 | 'payment_methods' => 'ALL', 79 | 'billing.firstname' => explode(' ', $data['name'])[0], 80 | 'billing.lastname' => explode(' ', $data['name'])[1], 81 | 'billing.address_1' => $data['address'], 82 | 'billing.city' => $data['city'] ?? 'Dar Es Salaam', 83 | 'billing.state_or_region' => $data['state'] ?? 'Dar Es Salaam', 84 | 'billing.postcode_or_pobox' => $data['postcode'], 85 | 'billing.country' => $data['country_code'] ?? 'TZ', 86 | 'billing.phone' => $data['billing_phone'] ?? $data['phone'], 87 | ]; 88 | } 89 | 90 | private function checkRequestFailure(Response $response) 91 | { 92 | if ($response->failed()) { 93 | return $response->json(); 94 | } 95 | } 96 | 97 | /** 98 | * @throws InvalidDataException 99 | */ 100 | private function handleOrderResponse(Response $response, array $data, string $orderId, $cardPayment = false) 101 | { 102 | $this->checkRequestFailure($response); 103 | 104 | $gatewayBuyerUuid = $data['buyer_uuid'] ?? $response['data'][0]['gateway_buyer_uuid'] ?? null; 105 | 106 | DB::table('selcom_payments')->insert(array_merge( 107 | [ 108 | 'amount' => (int) $data['amount'], 109 | 'order_id' => $orderId, 110 | 'transid' => $data['transaction_id'], 111 | 'created_at' => now(), 112 | ], 113 | ($gatewayBuyerUuid ? ['gateway_buyer_uuid' => $gatewayBuyerUuid] : []), 114 | (($data['user_id'] ?? false) ? ['user_id' => $data['user_id']] : []), 115 | )); 116 | 117 | if ($data['no_redirection'] ?? false) { 118 | return $cardPayment 119 | ? $this->makeCardPayment($data, $orderId, $gatewayBuyerUuid) 120 | : $this->makeWalletPullPayment($data, $orderId); 121 | } 122 | 123 | $url = base64_decode($response['data'][0]['payment_gateway_url']); 124 | 125 | return request()->expectsJson() 126 | ? response()->json(['payment_gateway_url' => $url]) 127 | : redirect($url); 128 | } 129 | 130 | private function makeWalletPullPayment(array $data, string $orderId) 131 | { 132 | return $this->makeRequest('checkout/wallet-payment', 'POST', [ 133 | 'transid' => $data['transaction_id'], 134 | 'order_id' => $orderId, 135 | 'msisdn' => $data['payment_phone'] ?? $data['phone'], 136 | ]) 137 | ->json(); 138 | } 139 | 140 | /** 141 | * @throws InvalidDataException 142 | */ 143 | private function makeCardPayment(array $data, string $orderId, $gatewayBuyerUuid) 144 | { 145 | if (is_null($gatewayBuyerUuid)) { 146 | throw new InvalidDataException( 147 | 'Provide the Buyer UUID for this user before making the request!' 148 | ); 149 | } 150 | 151 | $fetchCards = $this->makeRequest('checkout/stored-cards', 'GET', [ 152 | 'buyer_userid' => $data['user_id'], 153 | 'gateway_buyer_uuid' => $gatewayBuyerUuid, 154 | ]); 155 | 156 | $this->checkRequestFailure($fetchCards); 157 | 158 | if (! count($fetchCards['data'])) { 159 | throw new InvalidDataException("User doesn't have stored cards!"); 160 | } 161 | 162 | return rescue( 163 | fn() => $this->cardPayment($fetchCards['data'][0]['card_token'], $data, $orderId, $gatewayBuyerUuid), 164 | function () use ($fetchCards, $data, $orderId, $gatewayBuyerUuid) { 165 | if (count($fetchCards['data']) > 1) { 166 | return rescue( 167 | fn() => $this->cardPayment($fetchCards['data'][1]['card_token'], $data, $orderId, $gatewayBuyerUuid), 168 | fn() => count($fetchCards['data']) > 2 169 | ? $this->cardPayment($fetchCards['data'][2]['card_token'], $data, $orderId, $gatewayBuyerUuid) 170 | : null 171 | ); 172 | } 173 | 174 | return null; 175 | } 176 | ); 177 | } 178 | 179 | private function cardPayment(string $cardToken, array $data, string $orderId, $uuid) 180 | { 181 | return $this->makeRequest('checkout/card-payment', 'POST', [ 182 | 'transid' => $data['transaction_id'], 183 | 'vendor' => $this->vendor, 184 | 'order_id' => $orderId, 185 | 'card_token' => $cardToken, 186 | 'buyer_userid' => $data['user_id'], 187 | 'gateway_buyer_uuid' => $uuid, 188 | ]) 189 | ->json(); 190 | } 191 | 192 | public function fetchCards($userId, $buyerUuid) 193 | { 194 | return $this->makeRequest('checkout/stored-cards', 'GET', [ 195 | 'buyer_userid' => $userId, 196 | 'gateway_buyer_uuid' => $buyerUuid, 197 | ]) 198 | ->json(); 199 | } 200 | 201 | public function deleteCard($cardId, $buyerUuid) 202 | { 203 | return $this->makeRequest('checkout/delete-card', 'DELETE', [ 204 | 'id' => $cardId, 205 | 'gateway_buyer_uuid' => $buyerUuid, 206 | ]) 207 | ->json(); 208 | } 209 | 210 | public function processCheckoutWebhook() 211 | { 212 | DB::table('selcom_payments') 213 | ->where('transid', request('transid')) 214 | ->where('order_id', request('order_id')) 215 | ->update([ 216 | 'reference' => request('reference'), 217 | 'payment_status' => request('payment_status'), 218 | 'updated_at' => now(), 219 | ]); 220 | } 221 | 222 | public function orderStatus($orderId) 223 | { 224 | return $this->makeRequest('checkout/order-status', 'GET', [ 225 | 'order_id' => $orderId, 226 | ]) 227 | ->json(); 228 | } 229 | 230 | public function listOrders(string $fromDate, string $toDate) 231 | { 232 | return $this->makeRequest( 233 | 'checkout/list-orders', 234 | 'GET', 235 | compact('fromDate', 'toDate') 236 | ) 237 | ->json(); 238 | } 239 | 240 | public function cancelOrder($orderId) 241 | { 242 | return $this->makeRequest('checkout/cancel-order', 'DELETE', [ 243 | 'order_id' => $orderId, 244 | ]) 245 | ->json(); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/Traits/ValidatesData.php: -------------------------------------------------------------------------------- 1 | validate($this->getMinimalOrderKeys(), $data); 20 | } 21 | 22 | /** 23 | * @throws InvalidDataException 24 | * @throws MissingDataException 25 | */ 26 | public function validateCardCheckoutData($data) 27 | { 28 | if (($data['no_redirection'] ?? false) && ! Arr::has($data, ['user_id'])) { 29 | throw new InvalidDataException( 30 | 'You are missing the following: user_id. Otherwise, set no_redirection to false' 31 | ); 32 | } 33 | 34 | $this->validate( 35 | array_merge($this->getMinimalOrderKeys(), ['address', 'postcode']), 36 | $data 37 | ); 38 | } 39 | 40 | private function getMinimalOrderKeys(): array 41 | { 42 | return [ 43 | 'name', 44 | 'email', 45 | 'phone', 46 | 'amount', 47 | 'transaction_id', 48 | ]; 49 | } 50 | 51 | /** 52 | * @throws InvalidDataException 53 | * @throws MissingDataException 54 | */ 55 | private function validate($keys, $submittedData) 56 | { 57 | $missing = collect($keys) 58 | ->diff(collect(array_keys($submittedData))); 59 | 60 | if ($missing->count()) { 61 | throw new MissingDataException( 62 | "The following keys are missing from your data: {$missing->implode(', ')}" 63 | ); 64 | } 65 | 66 | if (isset($submittedData['name']) && 67 | count(explode(' ', $submittedData['name'])) < 2) 68 | { 69 | throw new InvalidDataException('Name must contain at-least 2 words'); 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /tests/Feature/Checkout/CheckoutTest.php: -------------------------------------------------------------------------------- 1 | requiredCheckoutData = [ 41 | 'name' => $this->faker->name(), 42 | 'email' => $this->faker->email(), 43 | 'phone' => $this->faker->phoneNumber(), 44 | 'amount' => $this->faker->randomNumber(5), 45 | 'transaction_id' => strtoupper($this->faker->bothify('##???#??#???')), 46 | ]; 47 | 48 | $this->cardCheckoutData = array_merge($this->requiredCheckoutData, [ 49 | 'address' => $this->faker->address(), 50 | 'postcode' => $this->faker->postcode(), 51 | ]); 52 | 53 | $createOrderResponse = Http::response(json_decode( 54 | file_get_contents(__DIR__ . '/../../stubs/create-order-response.json'), 55 | true 56 | )); 57 | 58 | $this->walletPaymentResponseData = json_decode( 59 | file_get_contents(__DIR__ . '/../../stubs/wallet-payment-response.json'), 60 | true 61 | ); 62 | 63 | $this->storedCardsResponseData = json_decode( 64 | file_get_contents(__DIR__ . '/../../stubs/stored-cards-response.json'), 65 | true 66 | ); 67 | 68 | $this->cardPaymentResponseData = json_decode( 69 | file_get_contents(__DIR__ . '/../../stubs/card-payment-response.json'), 70 | true 71 | ); 72 | 73 | $this->okResponseData = json_decode( 74 | file_get_contents(__DIR__ . '/../../stubs/ok-response-data.json'), 75 | true 76 | ); 77 | 78 | $urlPrefix = 'selcommobile.com/v1/checkout/'; 79 | 80 | Http::fake([ 81 | "${urlPrefix}create-order-minimal" => $createOrderResponse, 82 | "${urlPrefix}create-order" => $createOrderResponse, 83 | "${urlPrefix}wallet-payment" => Http::response($this->walletPaymentResponseData), 84 | "${urlPrefix}card-payment" => Http::response($this->cardPaymentResponseData), 85 | "${urlPrefix}delete-card" => Http::response($this->okResponseData), 86 | "${urlPrefix}order-status*" => Http::response($this->okResponseData), 87 | "${urlPrefix}list-orders*" => Http::response($this->okResponseData), 88 | "${urlPrefix}cancel-order" => Http::response($this->okResponseData), 89 | ]); 90 | } 91 | 92 | protected function tearDown(): void 93 | { 94 | parent::tearDown(); 95 | 96 | Mockery::close(); 97 | } 98 | 99 | /** @test */ 100 | public function test_sending_incomplete_checkout_data_throws_an_exception() 101 | { 102 | $this->expectException(MissingDataException::class); 103 | 104 | Selcom::checkout(Arr::except( 105 | $this->requiredCheckoutData, 106 | Arr::random(array_keys($this->requiredCheckoutData)) 107 | )); 108 | 109 | $response = Selcom::checkout($this->requiredCheckoutData); 110 | 111 | $this->assertInstanceOf(RedirectResponse::class, $response); 112 | } 113 | 114 | /** @test */ 115 | public function test_sending_incomplete_card_checkout_data_throws_an_exception() 116 | { 117 | $this->expectException(MissingDataException::class); 118 | 119 | Selcom::cardCheckout(Arr::except( 120 | $this->cardCheckoutData, 121 | Arr::random(array_keys($this->cardCheckoutData)) 122 | )); 123 | 124 | $response = Selcom::cardCheckout($this->cardCheckoutData); 125 | 126 | $this->assertInstanceOf(RedirectResponse::class, $response); 127 | } 128 | 129 | /** @test */ 130 | public function test_sending_incomplete_checkout_name_throws_an_exception() 131 | { 132 | $this->expectException(InvalidDataException::class); 133 | 134 | $this->expectExceptionMessage('Name must contain at-least 2 words'); 135 | 136 | $data = $this->requiredCheckoutData; 137 | 138 | $data['name'] = 'Bryce'; 139 | 140 | Selcom::checkout($data); 141 | } 142 | 143 | /** @test */ 144 | public function test_sending_incomplete_card_checkout_name_throws_an_exception() 145 | { 146 | $this->expectException(InvalidDataException::class); 147 | 148 | $this->expectExceptionMessage('Name must contain at-least 2 words'); 149 | 150 | $data = $this->cardCheckoutData; 151 | 152 | $data['name'] = 'Bryce'; 153 | 154 | Selcom::cardCheckout($data); 155 | } 156 | 157 | /** @test */ 158 | public function test_ussd_checkout_sends_back_data_without_redirecting() 159 | { 160 | $response = Selcom::checkout(array_merge( 161 | $this->requiredCheckoutData, 162 | ['no_redirection' => true] 163 | )); 164 | 165 | $this->assertEquals($response, $this->walletPaymentResponseData); 166 | } 167 | 168 | /** @test */ 169 | public function test_checkout_redirects_to_the_gateway_page() 170 | { 171 | $response = Selcom::checkout($this->requiredCheckoutData); 172 | 173 | $this->assertTrue($response instanceof RedirectResponse); 174 | } 175 | 176 | /** @test */ 177 | public function test_automatic_card_checkout_requires_user_data() 178 | { 179 | $this->expectException(InvalidDataException::class); 180 | 181 | $this->expectExceptionMessage( 182 | 'You are missing the following: user_id. Otherwise, set no_redirection to false' 183 | ); 184 | 185 | $data = $this->cardCheckoutData; 186 | 187 | $data['no_redirection'] = true; 188 | 189 | Selcom::cardCheckout($data); 190 | } 191 | 192 | /** @test */ 193 | public function test_automatic_card_payment_sends_data_without_redirecting() 194 | { 195 | Http::fake([ 196 | "selcommobile.com/v1/checkout/stored-cards*" => Http::response($this->storedCardsResponseData), 197 | ]); 198 | 199 | $response = Selcom::cardCheckout(array_merge( 200 | $this->cardCheckoutData, 201 | [ 202 | 'no_redirection' => true, 203 | 'user_id' => $this->faker->randomNumber(), 204 | ], 205 | // Randomly include uuid 206 | (Arr::random([0, 1]) ? ['buyer_uuid' => $this->faker->uuid()] : []) 207 | )); 208 | 209 | $this->assertEquals($response, $this->cardPaymentResponseData); 210 | } 211 | 212 | /** @test */ 213 | public function test_automatic_card_payment_without_created_cards_throws_an_exception() 214 | { 215 | Http::fake([ 216 | "selcommobile.com/v1/checkout/stored-cards*" => Http::response(['data' => []]), 217 | ]); 218 | 219 | $this->expectException(InvalidDataException::class); 220 | 221 | $this->expectExceptionMessage("User doesn't have stored cards!"); 222 | 223 | Selcom::cardCheckout(array_merge($this->cardCheckoutData, [ 224 | 'no_redirection' => true, 225 | 'user_id' => $this->faker->randomNumber(), 226 | 'buyer_uuid' => $this->faker->uuid(), 227 | ])); 228 | } 229 | 230 | /** @test */ 231 | public function test_order_details_are_saved_before_card_payments() 232 | { 233 | Http::fake([ 234 | "selcommobile.com/v1/checkout/stored-cards*" => Http::response($this->storedCardsResponseData), 235 | ]); 236 | 237 | $data = array_merge($this->cardCheckoutData, [ 238 | 'no_redirection' => true, 239 | 'user_id' => $this->faker->randomNumber(), 240 | 'buyer_uuid' => $this->faker->uuid(), 241 | ]); 242 | 243 | $response = Selcom::cardCheckout($data); 244 | 245 | $this->assertDatabaseHas('selcom_payments', [ 246 | 'user_id' => $data['user_id'], 247 | 'gateway_buyer_uuid' => $data['buyer_uuid'], 248 | 'transid' => $data['transaction_id'], 249 | 'amount' => $data['amount'], 250 | ]); 251 | 252 | $this->assertEquals($response, $this->cardPaymentResponseData); 253 | } 254 | 255 | /** @test */ 256 | public function test_order_details_are_saved_before_checkout_payments() 257 | { 258 | $data = array_merge($this->requiredCheckoutData, ['no_redirection' => true]); 259 | 260 | $response = Selcom::checkout($data); 261 | 262 | $this->assertDatabaseHas('selcom_payments', [ 263 | 'transid' => $data['transaction_id'], 264 | 'amount' => $data['amount'], 265 | ]); 266 | 267 | $this->assertEquals($response, $this->walletPaymentResponseData); 268 | } 269 | 270 | /** @test */ 271 | public function test_stored_cards_can_be_fetched() 272 | { 273 | Http::fake([ 274 | "selcommobile.com/v1/checkout/stored-cards*" => Http::response($this->storedCardsResponseData), 275 | ]); 276 | 277 | $response= Selcom::fetchCards($this->faker->randomNumber(), $this->faker->uuid()); 278 | 279 | $this->assertEquals($response, $this->storedCardsResponseData); 280 | } 281 | 282 | /** @test */ 283 | public function test_cards_can_be_deleted() 284 | { 285 | $response = Selcom::deleteCard($this->faker->randomNumber(), $this->faker->uuid()); 286 | 287 | $this->assertEquals($response, $this->okResponseData); 288 | } 289 | 290 | /** @test */ 291 | public function test_webhook_updates_payment_records() 292 | { 293 | $data = $this->requiredCheckoutData; 294 | 295 | Selcom::checkout($data); 296 | 297 | $this->assertDatabaseHas('selcom_payments', [ 298 | 'transid' => $data['transaction_id'], 299 | 'payment_status' => null, 300 | ]); 301 | 302 | $orderId = DB::table('selcom_payments') 303 | ->where('transid', $data['transaction_id']) 304 | ->value('order_id'); 305 | 306 | $this->post(route('selcom.checkout-callback'), [ 307 | 'transid' => $data['transaction_id'], 308 | 'order_id' => $orderId, 309 | 'reference' => '289124234', 310 | 'result' => 'SUCCESS', 311 | 'resultcode' => '000', 312 | 'payment_status' => 'COMPLETED', 313 | ]) 314 | ->assertOk(); 315 | 316 | $this->assertDatabaseHas('selcom_payments', [ 317 | 'transid' => $data['transaction_id'], 318 | 'payment_status' => 'COMPLETED', 319 | 'order_id' => $orderId, 320 | 'reference' => '289124234', 321 | ]); 322 | } 323 | 324 | /** @test */ 325 | public function test_webhook_dispatches_an_event() 326 | { 327 | $data = $this->requiredCheckoutData; 328 | 329 | Selcom::checkout($data); 330 | 331 | $orderId = DB::table('selcom_payments') 332 | ->where('transid', $data['transaction_id']) 333 | ->value('order_id'); 334 | 335 | Event::fake(); 336 | 337 | $this->post(route('selcom.checkout-callback'), [ 338 | 'transid' => $data['transaction_id'], 339 | 'order_id' => $orderId, 340 | 'reference' => '289124234', 341 | 'result' => 'SUCCESS', 342 | 'resultcode' => '000', 343 | 'payment_status' => 'COMPLETED', 344 | ]) 345 | ->assertOk(); 346 | 347 | Event::assertDispatched( 348 | fn (CheckoutWebhookReceived $event) => $event->orderId === $orderId 349 | ); 350 | } 351 | 352 | /** @test */ 353 | public function test_order_statuses_can_be_queried() 354 | { 355 | $response = Selcom::orderStatus($this->faker->uuid()); 356 | 357 | $this->assertEquals($response, $this->okResponseData); 358 | } 359 | 360 | /** @test */ 361 | public function test_orders_can_be_listed() 362 | { 363 | $response = Selcom::listOrders($this->faker->date(), $this->faker->date()); 364 | 365 | $this->assertEquals($response, $this->okResponseData); 366 | } 367 | 368 | /** @test */ 369 | public function test_orders_can_be_cancelled() 370 | { 371 | $response = Selcom::cancelOrder($this->faker->uuid()); 372 | 373 | $this->assertEquals($response, $this->okResponseData); 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /tests/Feature/Facades/SelcomFacadeTest.php: -------------------------------------------------------------------------------- 1 | app['config']->set('selcom.vendor', null); 15 | 16 | $this->expectException(MissingDataException::class); 17 | 18 | Selcom::makeRequest('', 'GET'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set([ 18 | 'selcom.vendor' => '12345ABC', 19 | 'selcom.key' => 'ABCDE', 20 | 'selcom.secret' => 'ABCDE', 21 | 'selcom.colors.header' => '#FF0012', 22 | 'selcom.colors.link' => '#FF0012', 23 | 'selcom.colors.button' => '#FF0012', 24 | 'selcom.expiry' => 60, 25 | 'database.default' => 'testdb', 26 | 'database.connections.testdb' => [ 27 | 'driver' => 'sqlite', 28 | 'database' => ':memory:', 29 | ], 30 | ]); 31 | } 32 | 33 | /** 34 | * Register service providers 35 | * 36 | * @param Application $app 37 | * @return array 38 | */ 39 | protected function getPackageProviders($app): array 40 | { 41 | return [ 42 | SelcomBaseServiceProvider::class, 43 | ]; 44 | } 45 | } -------------------------------------------------------------------------------- /tests/stubs/card-payment-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "reference" : "0289999288", 3 | "resultcode" : "111", 4 | "result" : "PENDING", 5 | "message" : "Request in progress. You will receive a callback shortly.", 6 | "data": [] 7 | } -------------------------------------------------------------------------------- /tests/stubs/create-order-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "reference" : "0289999288", 3 | "resultcode" : "000", 4 | "result" : "SUCCESS", 5 | "message" : "Order creation successful", 6 | "data": [{"gateway_buyer_uuid":"12344321", "payment_token":"80008000", "qr":"QR", "payment_gateway_url":"aHR0cDpleGFtcGxlLmNvbS9wZy90MTIyMjI="}] 7 | } 8 | -------------------------------------------------------------------------------- /tests/stubs/ok-response-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "reference" : "0289999288", 3 | "resultcode" : "000", 4 | "result" : "SUCCESS", 5 | "message" : "Delete successful", 6 | "data": [] 7 | } -------------------------------------------------------------------------------- /tests/stubs/stored-cards-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "reference" : "0289999288", 3 | "resultcode" : "000", 4 | "result" : "SUCCESS", 5 | "message" : "Order fetch successful", 6 | "data": [ 7 | { 8 | "masked_card":"5555-12XX-XXXX-1234", 9 | "creation_date":"2019-06-06 22:00:00", 10 | "card_token":"ABC123423232", 11 | "name":"JOE JOHN", 12 | "card_type":"001" 13 | }, 14 | { 15 | "masked_card":"5555-12XX-XXXX-4321", 16 | "creation_date":"2019-06-06 23:00:00", 17 | "card_token":"ABC123423244", 18 | "name":"JOE JOHN", 19 | "card_type":"001" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tests/stubs/wallet-payment-response.json: -------------------------------------------------------------------------------- 1 | { 2 | "reference" : "0289999288", 3 | "resultcode" : "111", 4 | "result" : "PENDING", 5 | "message" : "Request in progress. You will receive a callback shortly.", 6 | "data": [] 7 | } 8 | --------------------------------------------------------------------------------