├── .php_cs.dist.php ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── laravel-multipay.php ├── database ├── factories │ └── PaymentFactory.php └── migrations │ ├── 2021_09_24_104517_create_payments_table.php │ ├── 2021_10_26_104517_create_metadata_column_in_payments_table.php │ ├── 2022_11_16_104517_add_soft_deletes_to_payments_table.php │ ├── 2022_12_08_074517_declare_unique_tables_in_payments.php │ ├── 2023_06_05_074517_make_user_id_optional.php │ ├── 2023_07_24_074517_subscription_service.php │ ├── 2023_08_19_000000_create_customers_table.php │ ├── 2023_09_24_000000_add_model_to_customers.php │ └── 2023_09_24_010000_add_metadata_to_subscriptions.php ├── laravel-multipay-logo.png ├── phpstan.neon ├── publish.sh ├── routes └── web.php ├── src ├── Actions │ └── CreateNewPayment.php ├── Contracts │ └── PaymentHandlerInterface.php ├── Events │ └── SuccessfulLaravelMultipayPaymentEvent.php ├── Exceptions │ ├── MissingUserException.php │ ├── NonActionableWebhookPaymentException.php │ ├── PaymentNotFoundException.php │ ├── UnknownWebhookException.php │ ├── ValueException.php │ └── WrongPaymentHandlerException.php ├── Facades │ └── LaravelMultipay.php ├── Http │ ├── Controllers │ │ ├── PaymentController.php │ │ └── PaymentWebhookController.php │ └── Requests │ │ └── InitiatePaymentRequest.php ├── LaravelMultipayServiceProvider.php ├── Models │ ├── Customer.php │ ├── Payment.php │ ├── PaymentPlan.php │ └── Subscription.php ├── Services │ ├── PaymentHandlers │ │ ├── BasePaymentHandler.php │ │ ├── Flutterwave.php │ │ ├── Interswitch.php │ │ ├── Paystack.php │ │ ├── PaystackTerminal │ │ │ └── Terminal.php │ │ ├── Remita.php │ │ └── UnifiedPayments.php │ ├── PaymentService.php │ └── SubscriptionService.php ├── ValueObjects │ ├── PaystackVerificationResponse.php │ ├── ReQuery.php │ └── RemitaResponse.php └── Webhooks │ ├── Contracts │ └── WebhookHandler.php │ └── Paystack │ ├── ChargeSuccess.php │ ├── InvoicePaymentFailed.php │ ├── PaymentRequestPending.php │ └── PaymentRequestSuccess.php └── views ├── .gitkeep ├── confirm_transaction.blade.php ├── generic-auto-submitted-payment-form.blade.php ├── generic-confirm_transaction.blade.php ├── partials ├── payment-summary-generic.blade.php └── payment-summary-json.blade.php ├── payment-handler-specific ├── interswitch-form.blade.php ├── paystack-auto_submitted_form.blade.php └── remita-auto_submitted_form.blade.php ├── test-drive └── pay.blade.php ├── test └── layout.blade.php └── transaction-completed.blade.php /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'class_attributes_separation' => [ 30 | 'elements' => [ 31 | 'method' => 'one', 32 | ], 33 | ], 34 | 'method_argument_space' => [ 35 | 'on_multiline' => 'ensure_fully_multiline', 36 | 'keep_multiple_spaces_after_comma' => true, 37 | ], 38 | 'single_trait_insert_per_statement' => true, 39 | ]) 40 | ->setFinder($finder); 41 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Flutterwave", 4 | "paymentrequest", 5 | "Yabacon" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-multipay` will be documented in this file. 4 | 5 | ## 1.0.0 - 202X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) damms005 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Multipay 💸 2 | 3 | ![Art image for laravel-multipay](https://banners.beyondco.de/Laravel%20Multipay.png?theme=light&packageManager=composer+require&packageName=damms005%2Flaravel-multipay&pattern=glamorous&style=style_1&description=An+opinionated+Laravel+package+for+handling+payments%2C+complete+with+blade+views&md=1&showWatermark=1&fontSize=100px&images=cash&widths=350) 4 | 5 | ![GitHub](https://img.shields.io/github/license/damms005/laravel-multipay) 6 | ![GitHub tag (with filter)](https://img.shields.io/github/v/tag/damms005/laravel-multipay) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/damms005/laravel-multipay.svg)](https://packagist.org/packages/damms005/laravel-multipay) 8 | ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/damms005/laravel-multipay/run-tests.yml) 9 | 10 | An opinionated Laravel package to handle payments, complete with blade views, routing, and everything in-between. 11 | 12 | Whether you want to quickly bootstrap payment processing for your Laravel applications, or you want a way to test supported payment processors, this package's got you! 13 | 14 | > Although opinionated, this package allows you to "theme" the views. It achieves this theming by 15 | > `@extend()`ing whatever view you specify in `config('laravel-multipay.extended_layout')` (defaults to `layout.app`). 16 | 17 | ## Requirements: 18 | This package is [tested against:](https://github.com/damms005/laravel-multipay/blob/d1a15bf762ba2adabc97714f1565c6c0f0fcd58d/.github/workflows/run-tests.yml#L16-17) 19 | - PHP ^8.1 20 | - Laravel 10/11 21 | 22 | ## Currently supported payment handlers 23 | 24 | Currently, this package supports the following online payment processors/handlers 25 | 26 | - [Paystack](https://paystack.com) 27 | - [Remita](http://remita.net) 28 | - [Flutterwave](https://flutterwave.com)** 29 | - [Interswitch](https://www.interswitchgroup.com)** 30 | - [UnifiedPayments](https://unifiedpayments.com)** 31 | 32 | _key_: 33 | `** implementation not yet complete for specified payment handler. PRs welcomed if you cannot afford to wait 😉` 34 | 35 | > Your preferred payment handler is not yet supported? Please consider [opening the appropriate issue type](https://github.com/damms005/laravel-multipay/issues/new?assignees=&labels=&template=addition-of-new-payment-handler.md&title=Addition+of+new+payment+handler+-+%5Bpayment+handler+name+here%5D). 36 | 37 | > Adding a new payment handler is straight-forward. Simply add a class that extends `Damms005\LaravelMultipay\Services\PaymentHandlers\BasePaymentHandler` and implement `Damms005\LaravelMultipay\Contracts\PaymentHandlerInterface` 38 | 39 | > **Note**
40 | > Payment providers that you so register as described above are resolvable from the [Laravel Container](https://laravel.com/docs/9.x/container) to improve the flexibility of this package and improve DX. 41 | 42 | ## Installation 43 | 44 | 1. Install via composer. 45 | 46 | ```bash 47 | composer require damms005/laravel-multipay 48 | ``` 49 | 50 | 1. Publish the config file. 51 | 52 | ```bash 53 | php artisan vendor:publish --tag=laravel-multipay-config 54 | ``` 55 | 56 | 1. Run migrations. 57 | 58 | ``` 59 | php artisan migrate 60 | ``` 61 | 62 | ### Demo Repo 63 | I published an open source app that uses this payment package. It is also an excellent example of a Laravel app that uses [Laravel Vite](https://laravel.com/docs/9.x/vite#main-content) and leverages on [Laravel Echo](https://laravel.com/docs/9.x/broadcasting#client-side-installation) to provide realtime experience via public and private channels using [Laravel Websocket](https://beyondco.de/docs/laravel-websockets), powered by [Livewire](https://laravel-livewire.com/docs). The app is called [NFT Marketplace. Click here to check it out ✌🏼](https://github.com/damms005/nft-marketplace) 64 | 65 | ### Test drive 🚀 66 | 67 | Want to take things for a spin? Visit `/payment/test-drive` (`route('payment.test-drive')` provided by this package) . 68 | For [Paystack](https://paystack.com), ensure to set `paystack_secret_key` key in the `laravel-multipay.php` config file that you published previously at installation. You can get your key from your [settings page](https://dashboard.paystack.co/#/settings/developer). 69 | 70 | > **Warning**
71 | > Ensure you have [TailwindCSS installed](https://tailwindcss.com/docs/installation), then add this package's views to the `content` key of your `tailwind.config.js` configuration file, like below: 72 | ``` 73 | content: [ 74 | ..., 75 | './vendor/damms005/laravel-multipay/views/**/*.blade.php', 76 | ], 77 | ... 78 | ``` 79 | 80 | ### Needed Third-party Integrations: 81 | 82 | - Flutterwave: If you want to use Flutterwave, ensure to get your API details [from the dashboard](https://dashboard.flutterwave.com/dashboard/settings/apis), and use it to set the following variables in your `.env` file: 83 | 84 | ``` 85 | FLW_PUBLIC_KEY=FLWPUBK-xxxxxxxxxxxxxxxxxxxxx-X 86 | FLW_SECRET_KEY=FLWSECK-xxxxxxxxxxxxxxxxxxxxx-X 87 | FLW_SECRET_HASH='My_lovelysite123' 88 | ``` 89 | 90 | - Paystack: Paystack requires a secret key. Go to [the Paystack dashboard](https://dashboard.paystack.co/#/settings/developer) to obtain one, and use it to set the following variable: 91 | 92 | ``` 93 | PAYSTACK_SECRET_KEY=FLWPUBK-xxxxxxxxxxxxxxxxxxxxx-X 94 | ``` 95 | 96 | - Remita: Ensure to set the following environment variables: 97 | 98 | ``` 99 | REMITA_MERCHANT_ID=xxxxxxxxxxxxxxxxxxxxx-X 100 | REMITA_API_KEY=xxxxxxxxxxxxxxxxxxxxx-X 101 | ``` 102 | 103 | > For most of the above environment variables, you should rather use the (published) config file to set the corresponding values. 104 | 105 | ## Usage 106 | 107 | ### Typical process-flow 108 | 109 | #### Step 1 110 | 111 | Send a `POST` request to `/payment/details/confirm` (`route('payment.show_transaction_details_for_user_confirmation')` provided by this package). 112 | 113 | Check the [InitiatePaymentRequest](src/Http/Requests/InitiatePaymentRequest.php#L28) form request class to know the values you are to post to this endpoint. (tip: you can also check [test-drive/pay.blade.php](views/test-drive/pay.blade.php)). 114 | 115 | This `POST` request will typically be made by submitting a form from your frontend to the route described above. 116 | 117 | > [!NOTE] 118 | > if you need to store additional/contextual data with this payment, you can include such data in the request, in a field named `metadata`. The value must be a valid JSON string. 119 | 120 | #### Step 2 121 | 122 | Upon user confirmation of transaction, user is redirected to the appropriate payment handler's gateway. 123 | 124 | #### Step 3 125 | 126 | When user is done with the transaction on the payment handler's end (either successfully paid, or declined transaction), user is redirected 127 | back to `/payment/completed` (`route('payment.finished.callback_url')` provided by this package) . 128 | 129 | > [!NOTE] 130 | > If the `Payment` has [`metadata`](#step-1) (supplied with the payment initiation request), with a key named `completion_url`, the user will be redirected to that URL instead on successful payment, with the transaction reference included as `transaction_reference` in the URL query string. 131 | 132 | > [!NOTE] 133 | > If the `Payment` has [`metadata`](#step-1) (supplied with the payment initiation request), and it contains a key named `payment_processor`, it will be used to dynamically set the payment handler for that particular transaction. Valid value is any of [the providers listed above](#currently-supported-payment-handlers) 134 | 135 | > [!NOTE] 136 | > If the `Payment` has [`metadata`](#step-1) (supplied with the payment initiation request), with a key named `split_code`, for Paystack transactions, it will be processed as [Paystack Multi-split Transaction](https://paystack.com/docs/payments/multi-split-payments). 137 | 138 | > [!NOTE] 139 | > If there are additional steps you want to take upon successful payment, listen for `SuccessfulLaravelMultipayPaymentEvent`. It will be fired whenever a successful payment occurs, with its corresponding `Payment` model. 140 | 141 | ## Payment Conflict Resolution (PCR) 142 | 143 | If for any reason, your user/customer claims that the payment they made was successful but that your platform did not reflect such successful payment, this PCR feature enables you to resolve such claims by simply calling: 144 | 145 | ``` 146 | /** 147 | * @var bool //true if payment was successful, false otherwise 148 | **/ 149 | $outcome = LaravelMultipay::reQueryUnsuccessfulPayment( $payment ) 150 | ``` 151 | 152 | The payment will be re-resolved and the payment will be updated in the database. If the payment is successful, the `SuccessfulLaravelMultipayPaymentEvent` event will be fired, so you can run any domain/application-specific procedures. 153 | 154 | ## Payment Notifications (WebHooks) 155 | Some payment handlers provide a means for sending details of successful notifications. Usually, you will need to provide the payment handler with a URL to which the details of such notification will be sent. Should you need this feature, the notification URL is handled by `route('payment.external-webhook-endpoint' provided by this package)`. 156 | 157 | > If you use this payment notification URL feature, ensure that in the handler for `SuccessfulLaravelMultipayPaymentEvent`, you have not previously handled the event for that same payment. 158 | 159 | ## Testing 160 | 161 | ```bash 162 | composer test 163 | ``` 164 | 165 | ## Credits 166 | 167 | This package is made possible by the nice works done by the following awesome projects: 168 | 169 | - [yabacon/paystack-php](https://github.com/yabacon/paystack-php) 170 | - [kingflamez/laravelrave](https://github.com/kingflamez/laravelrave) 171 | 172 | ## License 173 | 174 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 175 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "damms005/laravel-multipay", 3 | "description": "An opinionated, easily extendable and configurable package for handling payments in Laravel", 4 | "keywords": [ 5 | "laravel", 6 | "laravel payments", 7 | "multiple payments", 8 | "multiple payments providers", 9 | "configurable laravel payments", 10 | "damms005", 11 | "Damilola Olowookere" 12 | ], 13 | "homepage": "https://github.com/damms005/laravel-multipay", 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Damilola Olowookere", 18 | "email": "damms005@gmail.com", 19 | "role": "Developer" 20 | } 21 | ], 22 | "require": { 23 | "php": "^8.1", 24 | "damms005/laravel-flutterwave": "^2.0", 25 | "flutterwavedev/flutterwave-v3": "^1.0", 26 | "guzzlehttp/guzzle": "^7.3", 27 | "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0", 28 | "yabacon/paystack-php": "^2.2" 29 | }, 30 | "require-dev": { 31 | "doctrine/dbal": "^3.6", 32 | "larastan/larastan": "^2.9", 33 | "orchestra/testbench": "^9.0", 34 | "pestphp/pest": "^3.0", 35 | "pestphp/pest-plugin-laravel": "^3.0", 36 | "pestphp/pest-plugin-watch": "^3.1" 37 | }, 38 | "autoload": { 39 | "psr-4": { 40 | "Damms005\\LaravelMultipay\\": "src", 41 | "Damms005\\LaravelMultipay\\Database\\Factories\\": "database/factories" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Damms005\\LaravelMultipay\\Tests\\": "tests" 47 | } 48 | }, 49 | "scripts": { 50 | "test": "./vendor/bin/pest --no-coverage --retry --watch", 51 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 52 | }, 53 | "config": { 54 | "sort-packages": true, 55 | "allow-plugins": { 56 | "pestphp/*": true 57 | } 58 | }, 59 | "extra": { 60 | "laravel": { 61 | "providers": [ 62 | "Damms005\\LaravelMultipay\\LaravelMultipayServiceProvider" 63 | ], 64 | "aliases": { 65 | "LaravelMultipay": "Damms005\\LaravelMultipay\\Facades\\LaravelMultipay" 66 | } 67 | } 68 | }, 69 | "minimum-stability": "dev", 70 | "prefer-stable": true 71 | } 72 | -------------------------------------------------------------------------------- /config/laravel-multipay.php: -------------------------------------------------------------------------------- 1 | App\Models\User::class, 14 | 15 | /** 16 | * For 'user_model_fqcn' above, provide the name of the column 17 | * that corresponds to the user model's primary key 18 | */ 19 | 'user_model_owner_key' => 'id', 20 | 21 | /** 22 | * For 'user_model_fqcn' above, provide the names of the model properties 23 | * that correspond to the payer's name, email and phone number. 24 | * These can be direct column names or Eloquent model accessors. 25 | */ 26 | 'user_model_properties' => [ 27 | 'name' => 'name', 28 | 'email' => 'email', 29 | 'phone' => 'phone', 30 | ], 31 | 32 | /** 33 | * The layout to extend when displaying views 34 | */ 35 | 'extended_layout' => 'layouts.app', 36 | 37 | /** 38 | * In the layout extended, provide the name of the section 39 | * that yields the content 40 | */ 41 | 'section_name' => 'content', 42 | 43 | /** 44 | * String to pre-pend to database table names 45 | */ 46 | 'table_prefix' => '', 47 | 48 | /** 49 | * Path name under which the routes of this package will be defined 50 | */ 51 | 'payment_route_path' => '/payment', 52 | 53 | 'paystack_secret_key' => env('PAYSTACK_SECRET_KEY'), 54 | 55 | 'default_payment_handler_fqcn' => Damms005\LaravelMultipay\Services\PaymentHandlers\Paystack::class, 56 | 57 | //https://remitademo.net/remita 58 | 'remita_base_request_url' => env('REMITA_BASE_REQUEST_URL', 'https://login.remita.net/remita'), 59 | 'remita_merchant_id' => env('REMITA_MERCHANT_ID'), 60 | 'remita_api_key' => env('REMITA_API_KEY'), 61 | 62 | 'payment_confirmation_notice' => env( 63 | 'PAYMENT_CONFIRMATION_NOTICE', 64 | 'The details of your transaction is given below. Kindly print this page first before proceeding to click on Pay Now (this ensures that you have your transaction reference in case you need to refer to this transaction in the future).' 65 | ), 66 | 67 | 'enable_payment_confirmation_page_print' => env('ENABLE_PAYMENT_CONFIRMATION_PAGE_PRINT', true), 68 | 69 | 'middleware' => [ 70 | ], 71 | ]; 72 | -------------------------------------------------------------------------------- /database/factories/PaymentFactory.php: -------------------------------------------------------------------------------- 1 | 1, 16 | 'original_amount_displayed_to_user' => 123, 17 | 'transaction_currency' => 'NGN', 18 | 'transaction_description' => 'Awesome payment', 19 | 'transaction_reference' => '123-ABC', 20 | 'payment_processor_name' => 'Paystack', 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /database/migrations/2021_09_24_104517_create_payments_table.php: -------------------------------------------------------------------------------- 1 | getTable(), function (Blueprint $table) { 18 | $table->id(); 19 | $table->integer("user_id"); 20 | $table->string("product_id")->nullable(); 21 | $table->bigInteger("original_amount_displayed_to_user"); 22 | $table->string("transaction_currency"); //in ISO-4217 format 23 | $table->string("transaction_description"); 24 | $table->string("transaction_reference"); 25 | $table->string("payment_processor_name"); 26 | $table->integer("pay_item_id")->nullable(); 27 | $table->string("processor_transaction_reference")->nullable(); 28 | $table->string("processor_returned_response_code")->nullable(); 29 | $table->string("processor_returned_card_number")->nullable(); 30 | $table->text("processor_returned_response_description")->nullable(); 31 | $table->string("processor_returned_amount")->nullable(); 32 | $table->timestamp("processor_returned_transaction_date")->nullable(); 33 | $table->string("customer_checkout_ip_address")->nullable(); 34 | $table->boolean("is_success")->nullable(); 35 | $table->integer("retries_count")->nullable(); 36 | $table->string("completion_url") 37 | ->comment("the url to redirect user after all is completed (notwithstanding if success/failure transaction. Just a terminal endpoint)") 38 | ->nullable(); 39 | $table->timestamps(); 40 | }); 41 | } 42 | 43 | /** 44 | * Reverse the migrations. 45 | * 46 | * @return void 47 | */ 48 | public function down() 49 | { 50 | Schema::dropIfExists((new Payment())->getTable()); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /database/migrations/2021_10_26_104517_create_metadata_column_in_payments_table.php: -------------------------------------------------------------------------------- 1 | getTable(), function (Blueprint $table) { 13 | $table->json("metadata") 14 | ->after("completion_url") 15 | ->nullable(); 16 | }); 17 | } 18 | 19 | /** 20 | * Reverse the migrations. 21 | * 22 | * @return void 23 | */ 24 | public function down() 25 | { 26 | Schema::table((new Payment())->getTable(), function (Blueprint $table) { 27 | $table->dropColumn("metadata"); 28 | }); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /database/migrations/2022_11_16_104517_add_soft_deletes_to_payments_table.php: -------------------------------------------------------------------------------- 1 | getTable(), function (Blueprint $table) { 12 | $table->softDeletes(); 13 | }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /database/migrations/2022_12_08_074517_declare_unique_tables_in_payments.php: -------------------------------------------------------------------------------- 1 | getTable(), function (Blueprint $table) { 12 | $table->string("transaction_reference")->unique()->change(); 13 | $table->string("processor_transaction_reference")->unique()->nullable()->change(); 14 | }); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /database/migrations/2023_06_05_074517_make_user_id_optional.php: -------------------------------------------------------------------------------- 1 | getTable(), function (Blueprint $table) { 12 | $table->bigInteger('user_id')->nullable()->change(); 13 | }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /database/migrations/2023_07_24_074517_subscription_service.php: -------------------------------------------------------------------------------- 1 | getTable(), function (Blueprint $table) { 13 | $table->id(); 14 | $table->string('name')->unique(); 15 | $table->string('amount'); 16 | $table->string('interval'); 17 | $table->string('description'); 18 | $table->string('currency'); 19 | $table->string('payment_handler_fqcn'); // the fully qualified class name of the payment handler 20 | $table->string('payment_handler_plan_id'); // the id of the plan on the payment handler's platform 21 | $table->timestamps(); 22 | $table->softDeletes(); 23 | }); 24 | 25 | Schema::create((new Subscription())->getTable(), function (Blueprint $table) { 26 | $table->id(); 27 | $table->bigInteger('user_id'); 28 | $table->bigInteger('payment_plan_id'); 29 | $table->dateTime('next_payment_due_date'); 30 | $table->timestamps(); 31 | $table->softDeletes(); 32 | }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /database/migrations/2023_08_19_000000_create_customers_table.php: -------------------------------------------------------------------------------- 1 | id(); 17 | $table->bigInteger('user_id'); 18 | $table->string('customer_id'); 19 | $table->json('metadata')->nullable(); 20 | $table->timestamps(); 21 | $table->softDeletes(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('customers'); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /database/migrations/2023_09_24_000000_add_model_to_customers.php: -------------------------------------------------------------------------------- 1 | string('model')->nullable(false)->default()->after('id'); 17 | }); 18 | 19 | Schema::table('customers', function (Blueprint $table) { 20 | $table->renameColumn('user_id', 'model_id'); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::table('customers', function (Blueprint $table) { 32 | $table->dropColumn('model'); 33 | }); 34 | 35 | Schema::table('customers', function (Blueprint $table) { 36 | $table->renameColumn('model_id', 'user_id'); 37 | }); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /database/migrations/2023_09_24_010000_add_metadata_to_subscriptions.php: -------------------------------------------------------------------------------- 1 | getTable(), function (Blueprint $table) { 12 | $table->json('metadata')->nullable()->after('next_payment_due_date'); 13 | }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /laravel-multipay-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damms005/laravel-multipay/3b6adc3778f6051d6adf2b15aa29254a7c701b48/laravel-multipay-logo.png -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src 8 | 9 | # The level 9 is the highest level 10 | level: 5 11 | 12 | ignoreErrors: 13 | - 14 | messages: 15 | - '#^Access to an undefined property Illuminate\\Foundation\\Auth\\User\:\:\$email\.$#' 16 | path: src/Services/PaymentHandlers/Paystack.php 17 | - 18 | messages: 19 | - '#^Access to an undefined property Illuminate\\Foundation\\Auth\\User\:\:\$email\.$#' 20 | paths: 21 | - src/Services/PaymentHandlers/Flutterwave.php 22 | 23 | excludePaths: 24 | - ./*/*/FileToBeExcluded.php 25 | 26 | checkMissingIterableValueType: false 27 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Usage: ./publish.sh [major|minor|patch] 4 | 5 | VERSION_TYPE=$1 6 | 7 | if [[ -z "$VERSION_TYPE" ]]; then 8 | echo "Please provide a version type (major, minor, patch)" 9 | exit 1 10 | fi 11 | 12 | # Check for uncommitted changes 13 | if [[ -n "$(git status --porcelain)" ]]; then 14 | echo "Uncommitted changes found. Commit or stash your changes before tagging." 15 | exit 1 16 | fi 17 | 18 | # Get the latest version tag from git 19 | LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1)) 20 | 21 | # Increment version using semantic versioning 22 | NEW_VERSION=$(php -r " 23 | list(\$major, \$minor, \$patch) = explode('.', '$LATEST_TAG'); 24 | switch ('$VERSION_TYPE') { 25 | case 'major': \$major++; \$minor = 0; \$patch = 0; break; 26 | case 'minor': \$minor++; \$patch = 0; break; 27 | case 'patch': \$patch++; break; 28 | default: exit(1); 29 | } 30 | echo \$major . '.' . \$minor . '.' . \$patch; 31 | ") 32 | 33 | if [[ -z "$NEW_VERSION" ]]; then 34 | echo "Invalid version type" 35 | exit 1 36 | fi 37 | 38 | # Tag the new version and push to remote 39 | git tag $NEW_VERSION 40 | git push origin $NEW_VERSION 41 | 42 | echo "Tagged and pushed version $NEW_VERSION" 43 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | middleware(config('laravel-multipay.middleware', ['web'])) 11 | ->group(function () { 12 | 13 | Route::group(['middleware' => ['web']], function () { 14 | Route::post('/details/confirm', [PaymentController::class, 'confirm'])->name('payment.show_transaction_details_for_user_confirmation'); 15 | Route::post('/gateway/process', [PaymentController::class, 'sendToPaymentGateway'])->name('payment.confirmation.submit'); 16 | 17 | // Let's take it for a spin! 18 | Route::get('/test-drive', function () { 19 | throw_if(!Auth::check(), "Please setup authentication (e.g. with Laravel Breeze) and login before test-driving"); 20 | 21 | $userId = auth()->user()->id; 22 | $paymentProviders = BasePaymentHandler::getNamesOfPaymentHandlers(); 23 | 24 | return view('laravel-multipay::test-drive.pay', [ 25 | 'userId' => $userId, 26 | 'providers' => $paymentProviders, 27 | ]); 28 | }) 29 | ->name('payment.test-drive'); 30 | }); 31 | 32 | // Use 'api' route for payment completion callback because some payment providers do POST rather than GET 33 | Route::middleware('api') 34 | ->group(function () { 35 | 36 | // Route that users get redirected to when done with payment 37 | Route::match(['get', 'post'], '/completed', [PaymentController::class, 'handlePaymentGatewayResponse']) 38 | ->name('payment.finished.callback_url'); 39 | 40 | Route::match(['get', 'post'], '/completed/notify', PaymentWebhookController::class) 41 | ->name('payment.external-webhook-endpoint'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/Actions/CreateNewPayment.php: -------------------------------------------------------------------------------- 1 | $user_id, 21 | "completion_url" => $completion_url, 22 | "transaction_reference" => $transaction_reference, 23 | "payment_processor_name" => $payment_processor_name, 24 | "transaction_currency" => $currency, 25 | "transaction_description" => $transaction_description, 26 | "original_amount_displayed_to_user" => $original_amount_displayed_to_user, 27 | "metadata" => $metadata, 28 | ]); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Contracts/PaymentHandlerInterface.php: -------------------------------------------------------------------------------- 1 | payment = $payment; 31 | } 32 | 33 | /** 34 | * Get the channels the event should broadcast on. 35 | * 36 | * @return \Illuminate\Broadcasting\Channel|array 37 | */ 38 | public function broadcastOn() 39 | { 40 | return new PrivateChannel('channel-name'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Exceptions/MissingUserException.php: -------------------------------------------------------------------------------- 1 | getUniquePaymentHandlerName()}] {$reason}" 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Exceptions/NonActionableWebhookPaymentException.php: -------------------------------------------------------------------------------- 1 | getUniquePaymentHandlerName()}] could not create payment. Reason: {$reason}. {$paymentNotificationRequest->getContent()}" 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Exceptions/PaymentNotFoundException.php: -------------------------------------------------------------------------------- 1 | getUniquePaymentHandlerName()}] cannot handle webhook"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exceptions/ValueException.php: -------------------------------------------------------------------------------- 1 | getUniquePaymentHandlerName()}] value has already been given for this payment: transaction reference ({$flutterwaveReference})."); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exceptions/WrongPaymentHandlerException.php: -------------------------------------------------------------------------------- 1 | getUniquePaymentHandlerName()} cannot handle the provided payment: " . json_encode($payment)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Facades/LaravelMultipay.php: -------------------------------------------------------------------------------- 1 | make(PaymentService::class); 25 | 26 | $amount = $initiatePaymentRequest->amount; 27 | 28 | $description = $initiatePaymentRequest->transaction_description; 29 | $currency = $initiatePaymentRequest->currency; 30 | $transaction_reference = $initiatePaymentRequest->transaction_reference ?: strtoupper(Str::random(10)); 31 | $metadata = $this->getMetadata($initiatePaymentRequest); 32 | 33 | $view = $initiatePaymentRequest->filled('preferred_view') ? $initiatePaymentRequest->preferred_view : null; 34 | 35 | return $paymentService->storePaymentAndShowUserBeforeProcessing( 36 | $initiatePaymentRequest->input('user_id'), 37 | $amount, 38 | $description, 39 | $currency, 40 | $transaction_reference, 41 | $view, 42 | $metadata, 43 | $initiatePaymentRequest->input('payment_processor'), 44 | ); 45 | } 46 | 47 | public function sendToPaymentGateway(Request $request) 48 | { 49 | $request->validate([ 50 | 'transaction_reference' => 'required', 51 | ]); 52 | 53 | /** @var Payment */ 54 | $payment = Payment::where('transaction_reference', $request->transaction_reference)->firstOrFail(); 55 | 56 | // prevent duplicated transactions 57 | if ($payment->processor_returned_response_description) { 58 | return PaymentService::redirectWithError($payment, ["Multiple transactions detected: The transaction with reference number {$request->transaction_reference} is already completed."]); 59 | } 60 | 61 | /** @var PaymentHandlerInterface */ 62 | $basePaymentHandler = PaymentService::getHandlerFqcn($payment->payment_processor_name); 63 | 64 | return $basePaymentHandler->proceedToPaymentGateway($payment, route('payment.finished.callback_url'), true); 65 | } 66 | 67 | public function handlePaymentGatewayResponse(Request $request) 68 | { 69 | /** @var BasePaymentHandler */ 70 | $handler = app()->make(BasePaymentHandler::class); 71 | 72 | return $handler::handleServerResponseForTransactionAndDisplayOutcome($request); 73 | } 74 | 75 | protected function getMetadata(InitiatePaymentRequest $initiatePaymentRequest): array|null 76 | { 77 | $metadata = $this->formatMetadata($initiatePaymentRequest->input('metadata')); 78 | 79 | if ($initiatePaymentRequest->filled('payer_name')) { 80 | $metadata['payer_name'] = $initiatePaymentRequest->payer_name; 81 | } 82 | if ($initiatePaymentRequest->filled('payer_email')) { 83 | $metadata['payer_email'] = $initiatePaymentRequest->payer_email; 84 | } 85 | if ($initiatePaymentRequest->filled('payer_phone')) { 86 | $metadata['payer_phone'] = $initiatePaymentRequest->payer_phone; 87 | } 88 | 89 | return $metadata; 90 | } 91 | 92 | /** 93 | * The metadata column is cast as AsArrayObject. Hence, we need to ensure that any 94 | * value saved is not a string, else we risk getting a doubly-encoded string in db 95 | * as an effect of the AsArrayObject db casting 96 | * 97 | * @param mixed $metadata 98 | * 99 | */ 100 | protected function formatMetadata(mixed $metadata): array|null 101 | { 102 | if (empty($metadata)) { 103 | return null; 104 | } 105 | 106 | if (!is_string($metadata)) { 107 | return null; 108 | } 109 | 110 | return json_decode($metadata, true); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Http/Controllers/PaymentWebhookController.php: -------------------------------------------------------------------------------- 1 | paymentCompletionWebhookHandler($request); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Http/Requests/InitiatePaymentRequest.php: -------------------------------------------------------------------------------- 1 | ['required', 'string'], 31 | 32 | 'amount' => ['required', 'numeric'], 33 | 34 | 'user_id' => [ 35 | 'required_without_all:payer_name,payer_email,payer_phone', 36 | 'numeric', 37 | ], 38 | 'payer_name' => [ 39 | 'required_with:payer_email,payer_phone', 40 | 'required_without:user_id' 41 | ], 42 | 'payer_email' => [ 43 | 'required_with:payer_name,payer_phone', 44 | 'required_without:user_id', 45 | 'email', 46 | ], 47 | 'payer_phone' => [ 48 | 'required_with:payer_name,payer_email', 49 | 'required_without:user_id', 50 | ], 51 | 52 | 'transaction_description' => ['required', 'string'], 53 | 54 | 'metadata' => ['json'], 55 | 56 | 'payment_processor' => [ 57 | 'required', 58 | Rule::in(BasePaymentHandler::getNamesOfPaymentHandlers()), 59 | ], 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/LaravelMultipayServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__ . '/../database/migrations'); 15 | $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); 16 | $this->loadViewsFrom(__DIR__ . '/../views', 'laravel-multipay'); 17 | 18 | $this->publishes([__DIR__ . '/../config/laravel-multipay.php' => config_path('laravel-multipay.php')], 'laravel-multipay-config'); 19 | 20 | $this->bootFlutterwave(); 21 | 22 | $this->app->bind(BasePaymentHandler::class, function ($app) { 23 | $defaultPaymentHandler = config('laravel-multipay.default_payment_handler_fqcn'); 24 | 25 | if (!$defaultPaymentHandler) { 26 | throw new \Exception('Please provide a default payment handler in the laravel-multipay.php config file'); 27 | } 28 | 29 | return $app->make($defaultPaymentHandler); 30 | }); 31 | 32 | $this->app->bind('laravel-multipay', function ($app) { 33 | return $app->make(BasePaymentHandler::class); 34 | }); 35 | 36 | $this->app->bind(PaymentHandlerInterface::class, function ($app) { 37 | $defaultPaymentHandler = config('laravel-multipay.default_payment_handler_fqcn'); 38 | 39 | return new $defaultPaymentHandler(); 40 | }); 41 | 42 | $this->app->bind(PaymentService::class, function ($app) { 43 | return new PaymentService(); 44 | }); 45 | } 46 | 47 | public function register() 48 | { 49 | $this->mergeConfigFrom( 50 | __DIR__ . '/../config/laravel-multipay.php', 51 | 'laravel-multipay' 52 | ); 53 | } 54 | 55 | public function bootFlutterwave() 56 | { 57 | config(['laravel-multipay.flutterwave.publicKey' => env('FLW_PUBLIC_KEY')]); 58 | config(['laravel-multipay.flutterwave.secretKey' => env('FLW_SECRET_KEY')]); 59 | config(['laravel-multipay.flutterwave.secretHash' => env('FLW_SECRET_HASH')]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Models/Customer.php: -------------------------------------------------------------------------------- 1 | AsArrayObject::class, 48 | ]; 49 | 50 | protected const TABLE_NAME = 'payments'; 51 | public const KOBO_TO_NAIRA = 100; 52 | 53 | public function getTable(): string 54 | { 55 | $userDefinedTablePrefix = config('laravel-multipay.table_prefix'); 56 | 57 | if ($userDefinedTablePrefix) { 58 | return $userDefinedTablePrefix . self::TABLE_NAME; 59 | } 60 | 61 | return self::TABLE_NAME; 62 | } 63 | 64 | public function user() 65 | { 66 | return $this->belongsTo(config('laravel-multipay.user_model_fqcn'), 'user_id', config('laravel-multipay.user_model_owner_key')); 67 | } 68 | 69 | public function scopeSuccessful($query) 70 | { 71 | $query->where('is_success', 1); 72 | } 73 | 74 | /** 75 | * Gets the payment provider/handler for this payment 76 | */ 77 | public function getPaymentProvider(): BasePaymentHandler | PaymentHandlerInterface 78 | { 79 | $handler = Str::of(BasePaymentHandler::class) 80 | ->beforeLast("\\") 81 | ->append("\\") 82 | ->append($this->payment_processor_name) 83 | ->__toString(); 84 | 85 | return new $handler(); 86 | } 87 | 88 | public function getAmountInNaira() 89 | { 90 | if ($this->processor_returned_amount > 0) { 91 | return ((float) $this->processor_returned_amount) / 100; 92 | } 93 | 94 | return $this->processor_returned_amount; 95 | } 96 | 97 | public function getPayerName(): string 98 | { 99 | if ($this->user) { 100 | $nameProperty = config('laravel-multipay.user_model_properties.name'); 101 | return $this->user->$nameProperty; 102 | } 103 | 104 | if (!isset($this->metadata['payer_name'])) { 105 | throw new \Exception("payer name not found in metadata and no user is associated with this payment"); 106 | } 107 | 108 | return $this->metadata['payer_name']; 109 | } 110 | 111 | public function getPayerEmail(): string 112 | { 113 | if ($this->user) { 114 | $emailProperty = config('laravel-multipay.user_model_properties.email'); 115 | return $this->user->$emailProperty; 116 | } 117 | 118 | if (!isset($this->metadata['payer_email'])) { 119 | throw new \Exception("payer email not found in metadata and no user is associated with this payment"); 120 | } 121 | 122 | return $this->metadata['payer_email']; 123 | } 124 | 125 | public function getPayerPhone(): string 126 | { 127 | if ($this->user) { 128 | $phoneProperty = config('laravel-multipay.user_model_properties.phone'); 129 | return $this->user->$phoneProperty; 130 | } 131 | 132 | if (!isset($this->metadata['payer_phone'])) { 133 | throw new \Exception("payer phone not found in metadata and no user is associated with this payment"); 134 | } 135 | 136 | return $this->metadata['payer_phone']; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Models/PaymentPlan.php: -------------------------------------------------------------------------------- 1 | hasMany(Subscription::class); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Models/Subscription.php: -------------------------------------------------------------------------------- 1 | 'datetime', 19 | 'metadata' => AsArrayObject::class, 20 | ]; 21 | 22 | public function getTable(): string 23 | { 24 | $userDefinedTablePrefix = config('laravel-multipay.table_prefix'); 25 | 26 | if ($userDefinedTablePrefix) { 27 | return $userDefinedTablePrefix . self::TABLE_NAME; 28 | } 29 | 30 | return self::TABLE_NAME; 31 | } 32 | 33 | public function paymentPlan() 34 | { 35 | return $this->belongsTo(PaymentPlan::class); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Services/PaymentHandlers/BasePaymentHandler.php: -------------------------------------------------------------------------------- 1 | isUnregisteredPaymentHandler()) { 46 | throw new \Exception("Unregistered payment handler: {$this->getUniquePaymentHandlerName()}", 1); 47 | } 48 | } 49 | 50 | protected function isUnregisteredPaymentHandler() 51 | { 52 | return !collect(self::PAYMENT_PROVIDERS_FQCNs)->contains(static::class); 53 | } 54 | 55 | public static function getNamesOfPaymentHandlers() 56 | { 57 | return collect(self::PAYMENT_PROVIDERS_FQCNs) 58 | ->mapWithKeys(function (string $paymentHandlerFqcn) { 59 | /** @var PaymentHandlerInterface */ 60 | $paymentHandler = new $paymentHandlerFqcn(); 61 | 62 | return [$paymentHandlerFqcn => $paymentHandler->getUniquePaymentHandlerName()]; 63 | }); 64 | } 65 | 66 | public function getTransactionReferenceName(): string 67 | { 68 | return $this->getUniquePaymentHandlerName() . ' Transaction Reference'; 69 | } 70 | 71 | /** 72 | * This is where it all starts. It is like the initialization phase. User gets a chance to see summary of 73 | * transaction details before the payment handler proceeds to process the transaction. 74 | * Here is also where user gets the chance to save/note/print the 75 | * transaction reference/summary before proceed to payment. 76 | * This method should also create a record for this transaction in the database payments table. 77 | * 78 | */ 79 | public function storePaymentAndShowUserBeforeProcessing(?int $user_id, $original_amount_displayed_to_user, string $transaction_description, $currency, string $transaction_reference, string $completion_url = null, Request $optionalRequestForEloquentModelLinkage = null, $preferredView = null, ?array $metadata = null) 80 | { 81 | $payment = (new CreateNewPayment())->execute( 82 | $this->getUniquePaymentHandlerName(), 83 | $user_id, 84 | $completion_url, 85 | $transaction_reference, 86 | $currency, 87 | $transaction_description, 88 | $original_amount_displayed_to_user, 89 | $metadata 90 | ); 91 | 92 | if ($this->getUniquePaymentHandlerName() == UnifiedPayments::getUniquePaymentHandlerName()) { 93 | $payment->customer_checkout_ip_address = request()->ip(); 94 | $payment->save(); 95 | } 96 | 97 | $post_payment_confirmation_submit = route('payment.confirmation.submit'); 98 | 99 | if ($optionalRequestForEloquentModelLinkage) { 100 | $this->linkPaymentToEloquentModel($optionalRequestForEloquentModelLinkage, $payment); 101 | } 102 | 103 | $instructions = config('laravel-multipay.payment_confirmation_notice'); 104 | 105 | $exports = [ 106 | 'instructions' => $instructions, 107 | 'currency' => $currency, 108 | 'payment' => $payment, 109 | 'post_payment_confirmation_submit' => $post_payment_confirmation_submit, 110 | 'user_id' => $user_id 111 | ]; 112 | 113 | if (empty($preferredView)) { 114 | return view('laravel-multipay::generic-confirm_transaction', $exports); 115 | } else { 116 | return view($preferredView, $exports); 117 | } 118 | } 119 | 120 | public function isDefaultPaymentHandler(): bool 121 | { 122 | return self::class === config('laravel-multipay.default_payment_handler_fqcn'); 123 | } 124 | 125 | /** 126 | * Processes the outcome returned by payment gateway and return a view/response with details 127 | * of the transaction (e.g. successful, fail, etc.) 128 | * 129 | * @param Request $paymentGatewayServerResponse 130 | */ 131 | public static function handleServerResponseForTransactionAndDisplayOutcome(Request $paymentGatewayServerResponse) 132 | { 133 | $payment = self::sendNotificationForSuccessFulPayment($paymentGatewayServerResponse); 134 | 135 | throw_if(!$payment, "Could not retrieve payment"); 136 | 137 | [$paymentDescription, $isJsonDescription] = self::getPaymentDescription($payment); 138 | 139 | if ($payment->is_success) { 140 | if (self::paymentHasCustomSuccessPage($payment)) { 141 | return self::redirectToCustomSuccessPage($payment); 142 | } 143 | } 144 | 145 | return view('laravel-multipay::transaction-completed', [ 146 | 'payment' => $payment, 147 | 'isJsonDescription' => $isJsonDescription, 148 | 'paymentDescription' => $paymentDescription 149 | ]); 150 | } 151 | 152 | protected static function paymentHasCustomSuccessPage(Payment $payment) 153 | { 154 | /** @var ArrayObject */ 155 | $metadata = $payment->metadata; 156 | 157 | if (!$payment->metadata) { 158 | return; 159 | } 160 | 161 | $metadata = $metadata->toArray(); 162 | 163 | return array_key_exists('completion_url', $metadata) 164 | && trim($metadata['completion_url']); 165 | } 166 | 167 | protected static function redirectToCustomSuccessPage(Payment $payment) 168 | { 169 | $url = $payment->metadata['completion_url'] . "?transaction_reference=" . $payment->transaction_reference; 170 | 171 | return redirect()->away($url); 172 | } 173 | 174 | protected static function sendNotificationForSuccessFulPayment(Request $paymentGatewayServerResponse): ?Payment 175 | { 176 | /** 177 | * @var Payment 178 | */ 179 | $payment = null; 180 | 181 | /** @var PaymentService */ 182 | $paymentService = app()->make(PaymentService::class); 183 | 184 | collect(self::getNamesOfPaymentHandlers()) 185 | ->each(function (string $paymentHandlerName) use ($paymentGatewayServerResponse, $paymentService, &$payment) { 186 | $payment = $paymentService->handleGatewayResponse($paymentGatewayServerResponse, $paymentHandlerName); 187 | 188 | if ($payment) { 189 | if ($payment->is_success == 1) { 190 | event(new SuccessfulLaravelMultipayPaymentEvent($payment)); 191 | } 192 | 193 | return false; 194 | } 195 | }); 196 | 197 | return $payment; 198 | } 199 | 200 | /** 201 | * @return array [paymentDescription, isJsonDescription] 202 | */ 203 | protected static function getPaymentDescription(?Payment $payment): array 204 | { 205 | if (is_null($payment)) { 206 | return ['', false]; 207 | } 208 | 209 | $paymentDescription = json_decode($payment->processor_returned_response_description, true); 210 | $isJsonDescription = !is_null($paymentDescription); 211 | 212 | if (is_array($paymentDescription)) { 213 | $paymentDescription = collect($paymentDescription) 214 | ->mapWithKeys(function ($item, $key) { 215 | $humanReadableKey = Str::of($key) 216 | ->snake() 217 | ->title() 218 | ->replace("_", " ") 219 | ->__toString(); 220 | 221 | return [$humanReadableKey => $item]; 222 | }) 223 | ->toArray(); 224 | } 225 | 226 | return [$paymentDescription, $isJsonDescription]; 227 | } 228 | 229 | /** 230 | * @see \Damms005\LaravelMultipay\Contracts\PaymentHandlerInterface::reQuery() 231 | */ 232 | public function reQueryUnsuccessfulPayment(Payment $unsuccessfulPayment): ?ReQuery 233 | { 234 | /** @var PaymentHandlerInterface **/ 235 | $handler = new (PaymentService::getHandlerFqcn($unsuccessfulPayment->payment_processor_name)); 236 | 237 | $reQueryResponse = app(get_class($handler))->reQuery($unsuccessfulPayment); 238 | 239 | if ($reQueryResponse == null) { 240 | return null; 241 | } 242 | 243 | $isSuccessful = boolval($reQueryResponse->payment->refresh()->is_success); 244 | 245 | if ($isSuccessful) { 246 | event(new SuccessfulLaravelMultipayPaymentEvent($reQueryResponse->payment)); 247 | } 248 | 249 | return $reQueryResponse; 250 | } 251 | 252 | public function paymentCompletionWebhookHandler(Request $request) 253 | { 254 | $payment = $this->processWebhook($request); 255 | 256 | return $payment ? self::WEBHOOK_OKAY : null; 257 | } 258 | 259 | /** 260 | * @return ?Payment 261 | */ 262 | protected function processWebhook(Request $request): ?Payment 263 | { 264 | /** @var ?Payment */ 265 | $payment = collect(self::getNamesOfPaymentHandlers()) 266 | ->map(function (string $paymentHandlerName) use ($request) { 267 | $paymentHandler = (new PaymentService())->getPaymentHandlerByName($paymentHandlerName); 268 | 269 | try { 270 | $payment = $paymentHandler->handleExternalWebhookRequest($request); 271 | 272 | if ($payment->is_success) { 273 | event(new SuccessfulLaravelMultipayPaymentEvent($payment)); 274 | } 275 | 276 | return $payment; 277 | } catch (UnknownWebhookException $exception) { 278 | } 279 | }) 280 | ->filter() 281 | ->first(); 282 | 283 | return $payment; 284 | } 285 | 286 | public function getPayment(string $transaction_reference): Payment 287 | { 288 | return Payment::where('transaction_reference', $transaction_reference)->firstOrFail(); 289 | } 290 | 291 | public function getTransactedUser(string $transaction_reference) 292 | { 293 | return $this->getPayment($transaction_reference)->user; 294 | } 295 | 296 | private function linkPaymentToEloquentModel(Request $optionalRequestForEloquentModelLinkage, Payment $payment) 297 | { 298 | $validationEntries = [ 299 | 'update_model_success' => 'required:', 300 | 'update_model_unique_column' => 'required_with:update_model_success', 301 | 'update_model_unique_column_value' => 'required_with:update_model_unique_column', 302 | ]; 303 | 304 | $optionalRequestForEloquentModelLinkage->validate(array_keys($validationEntries)); 305 | 306 | $model = (new $optionalRequestForEloquentModelLinkage->update_model_success()) 307 | ->where($optionalRequestForEloquentModelLinkage->update_model_unique_column, $optionalRequestForEloquentModelLinkage->update_model_unique_column_value) 308 | ->first(); 309 | 310 | if ($model) { 311 | $model->update(['payment_id' => $payment->id]); 312 | } 313 | } 314 | 315 | public static function getUniquePaymentHandlerName(): string 316 | { 317 | return Str::of(static::class)->afterLast("\\"); 318 | } 319 | 320 | public function paymentIsUnsettled(Payment $payment): bool 321 | { 322 | throw new \Exception(static::class . " does not support checking if payment is unsettled"); 323 | } 324 | 325 | public function resumeUnsettledPayment(Payment $payment): mixed 326 | { 327 | throw new \Exception(static::class . " cannot resume previous payment session"); 328 | } 329 | 330 | public function createPaymentPlan(string $name, string $amount, string $interval, string $description, string $currency): string 331 | { 332 | throw new \Exception(static::class . " does not support creating payment plan"); 333 | } 334 | 335 | public function subscribeToPlan(User $user, PaymentPlan $plan, string $transactionReference): string 336 | { 337 | throw new \Exception(static::class . " does not support subscribing to payment plan"); 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/Services/PaymentHandlers/Flutterwave.php: -------------------------------------------------------------------------------- 1 | set('flutterwave.publicKey', config('laravel-multipay.flutterwave.publicKey')); 28 | config()->set('flutterwave.secretKey', config('laravel-multipay.flutterwave.secretKey')); 29 | config()->set('flutterwave.secretHash', config('laravel-multipay.flutterwave.secretHash')); 30 | config()->set('flutterwave.encryptionKey' . config('laravel-multipay.flutterwave.env', '')); 31 | } 32 | 33 | public function proceedToPaymentGateway(Payment $payment, $redirect_or_callback_url, $getFormForTesting = true): mixed 34 | { 35 | $transaction_reference = $payment->transaction_reference; 36 | 37 | return $this->sendUserToPaymentGateway($redirect_or_callback_url, $this->getPayment($transaction_reference)); 38 | } 39 | 40 | public function getHumanReadableTransactionResponse(Payment $payment): string 41 | { 42 | return ''; 43 | } 44 | 45 | public function convertResponseCodeToHumanReadable($responseCode): string 46 | { 47 | return ""; 48 | } 49 | 50 | protected function sendUserToPaymentGateway(string $redirect_or_callback_url, Payment $payment) 51 | { 52 | $transactionReference = strtoupper(FlutterwaveRave::generateReference()); 53 | 54 | // Enter the details of the payment 55 | $data = [ 56 | 'payment_options' => 'card', 57 | 'amount' => $payment->original_amount_displayed_to_user, 58 | 'email' => $payment->getPayerEmail(), 59 | 'tx_ref' => $transactionReference, 60 | 'currency' => "NGN", 61 | 'redirect_url' => $redirect_or_callback_url, 62 | 'customer' => [ 63 | 'email' => $payment->getPayerEmail(), 64 | "phone_number" => null, 65 | "name" => $payment->getPayerName(), 66 | ], 67 | 68 | "customizations" => [ 69 | "title" => 'Application fee payment', 70 | "description" => "Application fee payment", 71 | ], 72 | ]; 73 | 74 | $paymentInitialization = FlutterwaveRave::initializePayment($data); 75 | 76 | throw_if($paymentInitialization['status'] !== 'success', "Cannot initialize Flutterwave payment"); 77 | 78 | $url = $paymentInitialization['data']['link']; 79 | 80 | $payment->transaction_reference = $transactionReference; 81 | $payment->save(); 82 | 83 | header('Location: ' . $url); 84 | } 85 | 86 | public function confirmResponseCanBeHandledAndUpdateDatabaseWithTransactionOutcome(Request $paymentGatewayServerResponse): ?Payment 87 | { 88 | if (!$paymentGatewayServerResponse->has('tx_ref')) { 89 | return null; 90 | } 91 | 92 | $payment = $this->handleExternalWebhookRequest($paymentGatewayServerResponse); 93 | 94 | return $payment; 95 | } 96 | 97 | public function reQuery(Payment $existingPayment): ?ReQuery 98 | { 99 | throw new \Exception("Method not yet implemented"); 100 | } 101 | 102 | protected function giveValue($transactionReference, array $flutterwavePaymentDetails): ?Payment 103 | { 104 | /** 105 | * @var Payment 106 | */ 107 | $payment = Payment::where('transaction_reference', $transactionReference) 108 | ->firstOrFail(); 109 | 110 | // Ensure we have not already given value for this transaction 111 | if ($payment->is_success) { 112 | return null; 113 | } 114 | 115 | $payment->update([ 116 | "is_success" => 1, 117 | "processor_returned_amount" => $flutterwavePaymentDetails['data']['amount'], 118 | "processor_returned_transaction_date" => new Carbon($flutterwavePaymentDetails['data']['created_at']), 119 | 'processor_returned_response_description' => $flutterwavePaymentDetails['data']['processor_response'], 120 | ]); 121 | 122 | return $payment->fresh(); 123 | } 124 | 125 | protected function getConfig(): Config 126 | { 127 | return Config::setUp( 128 | config('laravel-multipay.flutterwave.secretKey'), 129 | config('laravel-multipay.flutterwave.publicKey'), 130 | config('laravel-multipay.flutterwave.secretHash'), 131 | config('laravel-multipay.flutterwave.env', ''), 132 | ); 133 | } 134 | 135 | public function createPaymentPlan(string $name, string $amount, string $interval, string $description, string $currency): string 136 | { 137 | $config = $this->getConfig(); 138 | $plansService = new PaymentPlan($config); 139 | 140 | $payload = new Payload(); 141 | $payload->set("amount", $amount); 142 | $payload->set("name", $name); 143 | $payload->set("interval", $interval); 144 | $payload->set("currency", $currency); 145 | $payload->set("duration", ''); 146 | 147 | $response = $plansService->create($payload); 148 | 149 | return $response->data->plan_token; 150 | } 151 | 152 | public function subscribeToPlan(User $user, PaymentPlanModel $plan, string $transactionReference): string 153 | { 154 | $data = [ 155 | 'tx_ref' => $transactionReference, 156 | 'amount' => $plan->amount, 157 | 'currency' => $plan->currency, 158 | 'redirect_url' => route('payment.finished.callback_url'), 159 | 'payment_options' => 'card', 160 | 'email' => $user->email, 161 | 'customer' => [ 162 | 'email' => $user->email, 163 | ], 164 | 'payment_plan' => $plan->payment_handler_plan_id, 165 | ]; 166 | 167 | $paymentInitialization = FlutterwaveRave::initializePayment($data); 168 | 169 | throw_if($paymentInitialization['status'] !== 'success', "Cannot initialize Flutterwave payment"); 170 | 171 | $url = $paymentInitialization['data']['link']; 172 | 173 | return $url; 174 | } 175 | 176 | /** 177 | * @see \Damms005\LaravelMultipay\Contracts\PaymentHandlerInterface::handleExternalWebhookRequest 178 | */ 179 | public function handleExternalWebhookRequest(Request $request): Payment 180 | { 181 | if (!$request->has('tx_ref')) { 182 | throw new UnknownWebhookException($this); 183 | } 184 | 185 | $transactionReference = $request->get('tx_ref'); 186 | $payment = Payment::where('transaction_reference', $transactionReference)->firstOrFail(); 187 | 188 | $status = $request->get('status'); 189 | 190 | if ($status != 'successful') { 191 | $payment->processor_returned_response_description = $status; 192 | $payment->save(); 193 | 194 | return $payment; 195 | } 196 | 197 | $transactionId = $request->get('transaction_id'); 198 | $flutterwavePaymentDetails = FlutterwaveRave::verifyTransaction($transactionId); 199 | 200 | if (!$this->isValidTransaction((array)$flutterwavePaymentDetails, $payment)) { 201 | $payment->processor_returned_response_description = "Invalid transaction"; 202 | $payment->save(); 203 | 204 | return $payment; 205 | } 206 | 207 | $payment = $this->giveValue($transactionReference, (array)$flutterwavePaymentDetails); 208 | 209 | $this->processPaymentMetadata($payment); 210 | 211 | return $payment; 212 | } 213 | 214 | protected function processPaymentMetadata(Payment $payment) 215 | { 216 | if (!is_iterable($payment->metadata)) { 217 | return; 218 | } 219 | 220 | $isPaymentForSubscription = array_key_exists('payment_plan_id', (array)$payment->metadata); 221 | 222 | if (!$isPaymentForSubscription) { 223 | return; 224 | } 225 | 226 | $plan = PaymentPlanModel::findOrFail($payment->metadata['payment_plan_id']); 227 | 228 | $nextPaymentDate = match ($plan->interval) { 229 | 'monthly' => Carbon::now()->addMonth(), 230 | 'yearly' => Carbon::now()->addYear(), 231 | default => throw new \Exception("Unknown interval {$plan->interval}"), 232 | }; 233 | 234 | Subscription::create([ 235 | 'user_id' => $payment->user_id, 236 | 'payment_plan_id' => $payment->metadata['payment_plan_id'], 237 | 'next_payment_due_date' => $nextPaymentDate, 238 | ]); 239 | } 240 | 241 | protected function getVerification(string $transactionId): stdClass 242 | { 243 | $transactionService = (new \Flutterwave\Service\Transactions()); 244 | 245 | $res = $transactionService->verify($transactionId); 246 | 247 | return $res; 248 | } 249 | 250 | public function isValidTransaction(array $flutterwavePaymentDetails, Payment $payment) 251 | { 252 | return $flutterwavePaymentDetails['data']['amount'] == $payment->original_amount_displayed_to_user; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/Services/PaymentHandlers/Interswitch.php: -------------------------------------------------------------------------------- 1 | user = $user; 31 | $this->amount_in_naira = $amount_in_naira; 32 | $this->txn_ref = $txn_ref; 33 | $this->site_redirect_url = $url_to_redirect_when_transaction_completed; 34 | 35 | return $this; 36 | } 37 | 38 | public function proceedToPaymentGateway(Payment $payment, $redirect_or_callback_url, bool $getFormForLiveApiNotTest = false): View|ViewFactory 39 | { 40 | return view('laravel-multipay::payment-handler-specific.interswitch-form', [ 41 | "hash" => $this->generateHashToSendInPaymentRequest(), 42 | "user" => $this->user, 43 | "amount" => $this->amount_in_naira * 100, //required amount to be posted in kobo 44 | "amount_in_naira" => $this->amount_in_naira, 45 | "txn_ref" => $this->txn_ref, 46 | "product_id" => self::PRODUCT_ID, 47 | "pay_item_id" => self::PAY_ITEM_ID, 48 | "site_redirect_url" => $this->site_redirect_url, 49 | ]); 50 | } 51 | 52 | public function confirmResponseCanBeHandledAndUpdateDatabaseWithTransactionOutcome(Request $paymentGatewayServerResponse): ?Payment 53 | { 54 | return null; 55 | } 56 | 57 | public function handleServerResponseOfUserPayment() 58 | { 59 | return $this->finalizePayment(); 60 | } 61 | 62 | protected function finalizePayment() 63 | { 64 | $transaction_status_string = $this->getTransactionStatus(); 65 | $transactionStatus = json_decode($transaction_status_string); 66 | 67 | if (json_last_error() === JSON_ERROR_NONE) { 68 | Log::debug($transaction_status_string); 69 | $payment = Payment::where('transaction_reference', $this->txn_ref)->firstOrFail(); 70 | $payment->is_success = $transactionStatus->ResponseCode == '00' ? true : false; 71 | $payment->processor_returned_amount = $transactionStatus->Amount; 72 | $payment->processor_returned_card_number = $transactionStatus->CardNumber ?? null; 73 | $payment->processor_transaction_reference = $transactionStatus->PaymentReference ?? null; 74 | $payment->processor_returned_response_code = $transactionStatus->ResponseCode; 75 | $payment->processor_returned_transaction_date = $transactionStatus->TransactionDate ?? null; 76 | $payment->processor_returned_response_description = $transactionStatus->ResponseDescription ?? null; 77 | 78 | $human_readable = $this->getHumanReadableTransactionResponse($payment); 79 | if (!empty($human_readable)) { 80 | if (empty($payment->processor_returned_response_description) || (trim($human_readable, ". \t\n\r\0\x0B") != trim($payment->processor_returned_response_description, ". \t\n\r\0\x0B"))) { 81 | $payment->processor_returned_response_description = $transactionStatus->ResponseDescription; 82 | } 83 | } 84 | 85 | $payment->save(); 86 | $this->user = $payment->user; 87 | $this->transaction_successful = true; 88 | } else { 89 | throw new \Exception("Transaction unsuccessful", 1); 90 | } 91 | } 92 | 93 | public function paymentIsSuccessful(Payment $payment): bool 94 | { 95 | return $this->transaction_successful; 96 | } 97 | 98 | public function generateHashToSendInPaymentRequest(): string 99 | { 100 | return hash('sha256', $this->txn_ref . self::PRODUCT_ID . self::PAY_ITEM_ID . "{$this->amount_in_naira}{$this->site_redirect_url}{$this->macKey}"); 101 | } 102 | 103 | public function getTransactionStatus() 104 | { 105 | $parameters = [ 106 | "amount" => $this->amount_in_naira * 100, 107 | "productid" => self::PRODUCT_ID, 108 | "transactionreference" => $this->txn_ref, 109 | ]; 110 | 111 | $query = http_build_query($parameters); 112 | $url = "{$this->requery_url}?{$query}"; 113 | 114 | //note the variables appended to the url as get values for these parameters 115 | $headers = [ 116 | "GET /HTTP/1.1", 117 | "User-Agent: Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.1) Gecko/2008070208 Firefox/3.0.1", 118 | "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 119 | "Accept-Language: en-us,en;q=0.5", 120 | "Keep-Alive: 300", 121 | "Connection: keep-alive", 122 | "Hash: " . hash('sha256', self::PRODUCT_ID . "{$this->txn_ref}{$this->macKey}"), 123 | ]; 124 | 125 | $ch = curl_init(); 126 | 127 | curl_setopt($ch, CURLOPT_URL, $url); 128 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 129 | curl_setopt($ch, CURLOPT_TIMEOUT, 60); 130 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 131 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); 132 | curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); 133 | curl_setopt($ch, CURLOPT_POST, false); 134 | 135 | $response = curl_exec($ch); 136 | 137 | if ($response === false) { 138 | throw new \Exception('Server error: ' . curl_error($ch), 1); 139 | } 140 | 141 | return $response; 142 | } 143 | 144 | public function getHumanReadableTransactionResponse(Payment $payment): string 145 | { 146 | $response_codes = $this->getResponseCodesArray(); 147 | $human_readable = array_key_exists($payment->processor_returned_response_code, $response_codes) ? $response_codes[$payment->processor_returned_response_code] : ""; 148 | 149 | return $human_readable; 150 | } 151 | 152 | public function reQuery(Payment $existingPayment): ?ReQuery 153 | { 154 | throw new \Exception("Method not yet implemented"); 155 | } 156 | 157 | /** 158 | * @see \Damms005\LaravelMultipay\Contracts\PaymentHandlerInterface::handleExternalWebhookRequest 159 | */ 160 | public function handleExternalWebhookRequest(Request $request): Payment 161 | { 162 | throw new UnknownWebhookException($this); 163 | } 164 | 165 | protected function getResponseCodesArray() 166 | { 167 | return [ 168 | "00" => "Approved by Financial Institution", 169 | "01" => "Refer to Financial Institution", 170 | "02" => "Refer to Financial Institution, Special Condition", 171 | "03" => "Invalid Merchant", 172 | "04" => "Pick-up card", 173 | "05" => "Do Not Honor", 174 | "06" => "Error", 175 | "07" => "Pick-Up Card, Special Condition", 176 | "08" => "Honor with Identification", 177 | "09" => "Request in Progress", 178 | "10" => "Approved by Financial Institution", 179 | "11" => "Approved by Financial Institution", 180 | "12" => "Invalid Transaction", 181 | "13" => "Invalid Amount", 182 | "14" => "Invalid Card Number", 183 | "15" => "No Such Financial Institution", 184 | "16" => "Approved by Financial Institution, Update Track 3", 185 | "17" => "Customer Cancellation", 186 | "18" => "Customer Dispute", 187 | "19" => "Re-enter Transaction", 188 | "20" => "Invalid Response from Financial Institution", 189 | "21" => "No Action Taken by Financial Institution", 190 | "22" => "Suspected Malfunction", 191 | "23" => "Unacceptable Transaction Fee", 192 | "24" => "File Update not Supported", 193 | "25" => "Unable to Locate Record", 194 | "26" => "Duplicate Record", 195 | "27" => "File Update Field Edit Error", 196 | "28" => "File Update File Locked", 197 | "29" => "File Update Failed", 198 | "30" => "Format Error", 199 | "31" => "Bank Not Supported", 200 | "32" => "Completed Partially by Financial Institution", 201 | "33" => "Expired Card, Pick-Up", 202 | "34" => "Suspected Fraud, Pick-Up", 203 | "35" => "Contact Acquirer, Pick-Up", 204 | "36" => "Restricted Card, Pick-Up", 205 | "37" => "Call Acquirer Security, Pick-Up", 206 | "38" => "PIN Tries Exceeded, Pick-Up", 207 | "39" => "No Credit Account", 208 | "40" => "Function not Supported", 209 | "41" => "Lost Card, Pick-Up", 210 | "42" => "No Universal Account", 211 | "43" => "Stolen Card, Pick-Up, Stolen Card, Pick-Up", 212 | "44" => "No Investment Account", 213 | "45" => "Account Closed", 214 | "51" => "Insufficient Funds", 215 | "52" => "No Check Account", 216 | "53" => "No Savings Account", 217 | "54" => "Expired Card", 218 | "55" => "Incorrect PIN", 219 | "56" => "No Card Record", 220 | "57" => "Transaction not Permitted to Cardholder", 221 | "58" => "Transaction not Permitted on Terminal", 222 | "59" => "Suspected Fraud", 223 | "60" => "Contact Acquirer", 224 | "61" => "Exceeds Withdrawal Limit", 225 | "62" => "Restricted Card", 226 | "63" => "Security Violation", 227 | "64" => "Original Amount Incorrect", 228 | "65" => "Exceeds withdrawal frequency", 229 | "66" => "Call Acquirer Security", 230 | "67" => "Hard Capture", 231 | "68" => "Response Received Too Late", 232 | "75" => "PIN tries exceeded", 233 | "76" => "Reserved for Future Postilion Use", 234 | "77" => "Intervene, Bank Approval Required", 235 | "78" => "Intervene, Bank Approval Required for Partial Amount", 236 | "90" => "Cut-off in Progress", 237 | "91" => "Issuer or Switch Inoperative", 238 | "Z1(92)" => "Routing Error.", 239 | "93" => "Violation of law", 240 | "94" => "Duplicate Transaction", 241 | "95" => "Reconcile Error", 242 | "96" => "System Malfunction", 243 | "98" => "Exceeds Cash Limit", 244 | "Z0" => "Transaction Not Completed", 245 | "Z4" => "Integration Error", 246 | "Z1(46)" => "Wrong login details on payment page attempting to login to QT", 247 | "Z1-a" => "(X10) 3d Secure Authenticate failed", 248 | "Z1-b" => "Transaction Error", 249 | "Z1-c" => "(XM1) Suspected Fraudulent Transaction", 250 | "Z5" => "Duplicate Transaction Reference", 251 | "Z6" => "Customer Cancellation", 252 | "Z25" => "Transaction not Found. Transaction you are querying does not exist on WebPAY", 253 | "Z30" => "Cannot retrieve risk profile", 254 | "Z61" => "Payment Requires Token.", 255 | "OTP" => "Cancellation", 256 | "Z62" => "Request to Generate Token is Successful", 257 | "Z63" => "Token Not Generated. Customer Not Registered on Token Platform", 258 | "Z64" => "Error Occurred. Could Not Generate Token", 259 | "Z65" => "Payment Requires Token Authorization", 260 | "Z66" => "Token Authorization Successful", 261 | "Z67" => "Token Authorization Not Successful. Incorrect Token Supplied", 262 | "Z68" => "Error Occurred. Could Not Authenticate Token", 263 | "Z69" => "Customer Cancellation Secure3D", 264 | "Z70" => "Cardinal Authentication Required", 265 | "Z71" => "Cardinal Lookup Successful", 266 | "Z72" => "Cardinal Lookup Failed / means the card didnt not exist on cardinal", 267 | "Z73" => "Cardinal Authenticate Successful", 268 | "Z74" => "Cardinal Authenticate Failed", 269 | "Z8" => "Invalid Card Details", 270 | "Z81" => "Bin has not been configured", 271 | "Z82" => "Merchant not configured for bin", 272 | "Z9" => "Cannot Connect to Passport Service", 273 | "Z15" => "Cannot Connect to Payphone Service", 274 | "Z16" => "Cannot Connect to Loyalty Service", 275 | "A3" => "Database Error", 276 | "A9" => "Incorrect Phone Number", 277 | "X04" => "Minimum Amount for Payment Item Not Met", 278 | "X03" => "Exceeds Maximum Amount Allowed", 279 | "T0" => "Token Request Successful", 280 | "T1" => "Token Request Failed", 281 | "T2" => "Token Authentication Pending", 282 | "S0" => "TimeOut calling postilion service", 283 | "S1" => "Invalid response from Postilion Service", 284 | "XG0" => "Cannot Retrieve Collections Account", 285 | "XG1" => "Successfully Retrieved Collections Account", 286 | "XG2" => "Could not retrieve collections account from key store", 287 | "PC1" => "Could not retrieve prepaid card number from key store", 288 | "XS1" => "Exceeded time period to completed transaction", 289 | "XNA" => " No acquirer found for mutli acquired payable", 290 | "AE1" => "Auto enrollment balance enquiry failed", 291 | "AE2" => "Auto enrollment account number and phone number validation failed", 292 | "AE3" => "Auto enrollment cannot add card to Safe token", 293 | "AE4" => "Auto enrollment error occurred", 294 | "E10" => "Missing service identifier: You have not specify a service provider for this request, please specify a valid service identifier.", 295 | "E11" => "Missing transaction type: You have not specify a transaction type for this request, please specify a valid transaction type", 296 | "E12" => "Missing authentication credentials. Security token header might be missing.", 297 | "E18" => "The service provider is unreachable at the moment", 298 | "E19" => "An invalid response was received from remote host, please contact system administrator.", 299 | "E20" => "Request as timed out", 300 | "E21" => "An unknown error has occurred, please contact system administrator.", 301 | "E34" => "System busy", 302 | "E42" => "invalid auth data error. Pan or expiry date is empty.", 303 | "E43" => "PIN cannot be empty", 304 | "E48" => "Invalid OTP identifier code", 305 | "E49" => "Invalid AuthDataVersion code", 306 | "E54" => "Could not get response from HSM", 307 | "E56" => "The PAN contains an invalid character", 308 | "E57" => "The PIN contains an invalid character", 309 | "E60" => "Invalid merchant code", 310 | "E61" => "Invalid payable code", 311 | "20021" => "No hash at all/no hash in requery", 312 | "20031" => "Invalid value for ProductId (in request or REQUERY) / amount must be supplied", 313 | "20050" => "Hash computation wrong/no hash in payment request", 314 | ]; 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/Services/PaymentHandlers/Paystack.php: -------------------------------------------------------------------------------- 1 | secret_key = config("laravel-multipay.paystack_secret_key"); 24 | 25 | if (empty($this->secret_key)) { 26 | // Paystack is currently the default payment handler (because 27 | // it is the easiest to setup and get up-and-running for starters/testing). Hence, 28 | // let the error message be contextualized, so we have a better UX for testers/first-timers 29 | if ($this->isDefaultPaymentHandler()) { 30 | throw new \Exception("You set Paystack as your default payment handler, but no Paystack Sk found. Please provide SK for Paystack."); 31 | } 32 | } 33 | } 34 | 35 | public function proceedToPaymentGateway(Payment $payment, $redirect_or_callback_url, $getFormForTesting = true): mixed 36 | { 37 | $transaction_reference = $payment->transaction_reference; 38 | 39 | return $this->sendUserToPaymentGateway($redirect_or_callback_url, $this->getPayment($transaction_reference)); 40 | } 41 | 42 | /** 43 | * This is a get request. (https://developers.paystack.co/docs/paystack-standard#section-4-verify-transaction) 44 | * 45 | * @param Request $paymentGatewayServerResponse 46 | * 47 | * @return Payment 48 | */ 49 | public function confirmResponseCanBeHandledAndUpdateDatabaseWithTransactionOutcome(Request $paymentGatewayServerResponse): ?Payment 50 | { 51 | if (!$paymentGatewayServerResponse->has('reference')) { 52 | return null; 53 | } 54 | 55 | return $this->processValueForTransaction($paymentGatewayServerResponse->reference); 56 | } 57 | 58 | /** 59 | * For Paystack, this is a get request. (https://developers.paystack.co/docs/paystack-standard#section-4-verify-transaction) 60 | */ 61 | public function processValueForTransaction(string $paystackReference): ?Payment 62 | { 63 | throw_if(empty($paystackReference)); 64 | 65 | $verificationResponse = $this->verifyPaystackTransaction($paystackReference); 66 | 67 | // status should be true if there was a successful call 68 | if (!$verificationResponse->status) { 69 | throw new \Exception($verificationResponse->message); 70 | } 71 | 72 | $payment = $this->resolveLocalPayment($paystackReference, $verificationResponse); 73 | 74 | if ('success' === $verificationResponse->data['status']) { 75 | if ($payment->payment_processor_name != $this->getUniquePaymentHandlerName()) { 76 | return null; 77 | } 78 | 79 | $this->giveValue($payment->transaction_reference, $verificationResponse); 80 | 81 | $payment->refresh(); 82 | } else { 83 | $payment->update([ 84 | 'is_success' => 0, 85 | 'processor_returned_response_description' => $verificationResponse->data['gateway_response'], 86 | ]); 87 | } 88 | 89 | return $payment; 90 | } 91 | 92 | public function reQuery(Payment $existingPayment): ?ReQuery 93 | { 94 | try { 95 | $verificationResponse = $this->verifyPaystackTransaction($existingPayment->processor_transaction_reference); 96 | } catch (\Throwable $th) { 97 | return new ReQuery($existingPayment, ['error' => $th->getMessage()]); 98 | } 99 | 100 | // status should be true if there was a successful call 101 | if (!$verificationResponse->status) { 102 | throw new \Exception($verificationResponse->message); 103 | } 104 | 105 | $payment = $this->resolveLocalPayment($existingPayment->processor_transaction_reference, $verificationResponse); 106 | 107 | if ('success' === $verificationResponse->data['status']) { 108 | if ($payment->payment_processor_name != $this->getUniquePaymentHandlerName()) { 109 | return null; 110 | } 111 | 112 | $this->giveValue($payment->transaction_reference, $verificationResponse); 113 | } else { 114 | $canStillBeSuccessful = in_array($verificationResponse->data['status'], ['ongoing', 'pending', 'processing', 'queued']); 115 | $payment->update([ 116 | 'is_success' => $canStillBeSuccessful 117 | ? null // so can still be selected for requery 118 | : false, 119 | 'processor_returned_response_description' => $verificationResponse->data['gateway_response'], 120 | ]); 121 | } 122 | 123 | return new ReQuery( 124 | payment: $payment, 125 | responseDetails: (array)$verificationResponse, 126 | ); 127 | } 128 | 129 | /** 130 | * @see \Damms005\LaravelMultipay\Contracts\PaymentHandlerInterface::handleExternalWebhookRequest 131 | */ 132 | public function handleExternalWebhookRequest(Request $request): Payment 133 | { 134 | $webhookEvents = [ 135 | ChargeSuccess::class, 136 | ]; 137 | 138 | foreach ($webhookEvents as $webhookEvent) { 139 | /** @var WebhookHandler */ 140 | $handler = new $webhookEvent(); 141 | 142 | if ($this->canHandleWebhook($handler, $request)) { 143 | return $handler->handle($request); 144 | } 145 | } 146 | 147 | throw new UnknownWebhookException($this); 148 | } 149 | 150 | protected function canHandleWebhook(WebhookHandler $handler, Request $request): bool 151 | { 152 | return $handler->isHandlerFor($request); 153 | } 154 | 155 | public function getHumanReadableTransactionResponse(Payment $payment): string 156 | { 157 | return ''; 158 | } 159 | 160 | public function convertResponseCodeToHumanReadable($responseCode): string 161 | { 162 | return ""; 163 | } 164 | 165 | protected function verifyPaystackTransaction($paystackReference): PaystackVerificationResponse 166 | { 167 | // Confirm that reference has not already gotten value 168 | // This would have happened most times if you handle the charge.success event. 169 | $paystack = app()->make(PaystackHelper::class, ['secret_key' => $this->secret_key]); 170 | 171 | // the code below throws an exception if there was a problem completing the request, 172 | // else returns an object created from the json response 173 | // (full sample verify response is here: https://developers.paystack.co/docs/verifying-transactions) 174 | 175 | return PaystackVerificationResponse::from( 176 | $paystack->transaction->verify(['reference' => $paystackReference]) 177 | ); 178 | } 179 | 180 | protected function convertAmountToValueRequiredByPaystack($original_amount_displayed_to_user) 181 | { 182 | return $original_amount_displayed_to_user * 100; //paystack only accept amount in kobo/lowest denomination of target currency 183 | } 184 | 185 | protected function sendUserToPaymentGateway(string $redirect_or_callback_url, Payment $payment) 186 | { 187 | $paystack = app()->make(PaystackHelper::class, ['secret_key' => $this->secret_key]); 188 | 189 | $payload = [ 190 | 'email' => $payment->getPayerEmail(), 191 | 'amount' => $this->convertAmountToValueRequiredByPaystack($payment->original_amount_displayed_to_user), 192 | 'callback_url' => $redirect_or_callback_url, 193 | ]; 194 | 195 | $splitCode = Arr::get($payment->metadata, 'split_code'); 196 | if (boolval(trim($splitCode))) { 197 | $payload['split_code'] = $splitCode; 198 | } 199 | 200 | // the code below throws an exception if there was a problem completing the request, 201 | // else returns an object created from the json response 202 | $trx = $paystack->transaction->initialize($payload); 203 | 204 | // status should be true if there was a successful call 205 | if (!$trx->status) { 206 | throw new \Exception($trx->message); 207 | } 208 | 209 | $payment = Payment::where('transaction_reference', $payment->transaction_reference) 210 | ->firstOrFail(); 211 | 212 | $metadata = is_null($payment->metadata) ? [] : (array)$payment->metadata; 213 | 214 | $payment->update([ 215 | 'processor_transaction_reference' => $trx->data->reference, 216 | 'metadata' => array_merge($metadata, [ 217 | 'paystack_authorization_url' => $trx->data->authorization_url 218 | ]), 219 | ]); 220 | 221 | // full sample initialize response is here: https://developers.paystack.co/docs/initialize-a-transaction 222 | // Get the user to click link to start payment or simply redirect to the url generated 223 | return redirect()->away($trx->data->authorization_url); 224 | } 225 | 226 | protected function giveValue(string $transactionReference, PaystackVerificationResponse $paystackResponse) 227 | { 228 | Payment::where('transaction_reference', $transactionReference) 229 | ->firstOrFail() 230 | ->update([ 231 | "is_success" => 1, 232 | "processor_returned_amount" => $paystackResponse->data['amount'], 233 | "processor_returned_transaction_date" => new Carbon($paystackResponse->data['created_at']), 234 | 'processor_returned_response_description' => $paystackResponse->data['gateway_response'], 235 | ]); 236 | } 237 | 238 | public function paymentIsUnsettled(Payment $payment): bool 239 | { 240 | return is_null($payment->is_success); 241 | } 242 | 243 | public function resumeUnsettledPayment(Payment $payment): mixed 244 | { 245 | if (!array_key_exists('paystack_authorization_url', (array)$payment->metadata)) { 246 | throw new \Exception("Attempt was made to resume a Paystack payment that does not have payment URL. Payment id is {$payment->id}"); 247 | } 248 | 249 | return redirect()->away($payment->metadata['paystack_authorization_url']); 250 | } 251 | 252 | public function createPaymentPlan(string $name, string $amount, string $interval, string $description, string $currency): string 253 | { 254 | $paystack = app()->make(PaystackHelper::class, ['secret_key' => $this->secret_key]); 255 | 256 | $paystack->plan->create([ 257 | 'name' => $name, 258 | 'amount' => $amount, // in lowest denomination. e.g. kobo 259 | 'interval' => $interval, // hourly, daily, weekly, monthly, quarterly, biannually (every 6 months) and annually 260 | 'description' => $description, 261 | 'currency' => $currency, // Allowed values are NGN, GHS, ZAR or USD 262 | ]); 263 | 264 | return ''; 265 | } 266 | 267 | protected function resolveLocalPayment(string $paystackReferenceNumber, PaystackVerificationResponse $verificationResponse): Payment 268 | { 269 | $isPosTerminalTransaction = is_object($verificationResponse->data['metadata']) && 270 | ($verificationResponse->data['metadata']->reference ?? false); 271 | 272 | return Payment::query() 273 | /** 274 | * normal transactions 275 | */ 276 | ->where('processor_transaction_reference', $paystackReferenceNumber) 277 | 278 | /** 279 | * terminal POS transactions 280 | */ 281 | ->when($isPosTerminalTransaction, function ($query) use ($verificationResponse) { 282 | return $query->orWhere( 283 | 'metadata->response->data->metadata->reference', 284 | $verificationResponse->data['metadata']->reference, 285 | ); 286 | }) 287 | ->firstOrFail(); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/Services/PaymentHandlers/PaystackTerminal/Terminal.php: -------------------------------------------------------------------------------- 1 | where('model_id', $modelId)->first(); 27 | $ref = Str::uuid()->toString(); 28 | 29 | if (!$customer) { 30 | $customer = $this->createCustomer($model, $modelId, $email); 31 | } 32 | 33 | $payload = [ 34 | 'customer' => $customer->customer_id, 35 | 'description' => $description, 36 | 'line_items' => [ 37 | ['name' => $description, 'amount' => $amount, 'quantity' => 1], 38 | ], 39 | 'metadata' => [ 40 | 'reference' => $ref, 41 | ] 42 | ]; 43 | 44 | $response = Http::acceptJson() 45 | ->withToken(config("laravel-multipay.paystack_secret_key")) 46 | ->post('https://api.paystack.co/paymentrequest', $payload) 47 | ->json(); 48 | 49 | if (!$response['status']) { 50 | throw new \Exception("Could not create payment request [customer id: {$customer->customer_id}]. " . json_encode($response)); 51 | } 52 | 53 | if (!Arr::get($response, 'data.id', false) || !Arr::get($response, 'data.offline_reference', false)) { 54 | throw new \Exception("Could not create payment request. Response id and offline reference are both needed. Received: " . json_encode($response)); 55 | } 56 | 57 | $metadata = [ 58 | 'customer_id' => $customer->id, 59 | 'response' => $response, 60 | ]; 61 | 62 | $payment = Payment::create([ 63 | 'original_amount_displayed_to_user' => $amount, 64 | 'transaction_currency' => $response['data']['currency'], 65 | 'transaction_description' => $description, 66 | 'transaction_reference' => $ref, 67 | 'payment_processor_name' => Paystack::getUniquePaymentHandlerName(), 68 | 'metadata' => $metadata, 69 | ]); 70 | 71 | return $payment; 72 | } 73 | 74 | public function waitForTerminalHardware() 75 | { 76 | $terminalId = session('multipay::paystack_terminal_id', config('laravel-multipay.paystack_terminal_id')); 77 | 78 | if (!$terminalId) { 79 | throw new \Exception("Terminal id is not set in the config file"); 80 | } 81 | 82 | $response = Http::acceptJson() 83 | ->withToken(config("laravel-multipay.paystack_secret_key")) 84 | ->get("https://api.paystack.co/terminal/{$terminalId}/presence"); 85 | 86 | $responseJson = $response->json(); 87 | 88 | if (Arr::get($responseJson, 'data.online', false) === false) { 89 | throw new \Exception("Terminal hardware error: " . $response->body()); 90 | } 91 | 92 | return "Terminal hardware status: " . $response->body(); 93 | } 94 | 95 | /** 96 | * @return string The event id. It is useful for tracking the event. 97 | * 98 | * @see https://paystack.com/docs/terminal/push-payment-requests/#verify-event-delivery 99 | */ 100 | public function pushToTerminal(Payment $payment): string 101 | { 102 | $terminalId = config("laravel-multipay.paystack_terminal_id"); 103 | $response = Http::acceptJson() 104 | ->withToken(config("laravel-multipay.paystack_secret_key")) 105 | ->post("https://api.paystack.co/terminal/{$terminalId}/event", [ 106 | 'type' => 'invoice', 107 | 'action' => 'process', 108 | 'data' => [ 109 | 'id' => $payment->metadata['response']['data']['id'], 110 | 'reference' => $payment->metadata['response']['data']['offline_reference'], 111 | ], 112 | ]); 113 | 114 | $responseJson = $response->json(); 115 | 116 | if (is_null($responseJson)) { 117 | throw new \Exception("Could not push to terminal. " . $response->body()); 118 | } 119 | 120 | if (!$responseJson['status']) { 121 | throw new \Exception("Could not push to terminal. " . json_encode($responseJson)); 122 | } 123 | 124 | return $responseJson['data']['id']; 125 | } 126 | 127 | /** 128 | * You can only confirm that a device received an event within 48 hours from the request initiation 129 | * 130 | * @see https://paystack.com/docs/terminal/push-payment-requests/#verify-event-delivery 131 | */ 132 | public function terminalReceivedPaymentRequest(string $paymentEventId): bool 133 | { 134 | $terminalId = config("laravel-multipay.paystack_terminal_id"); 135 | $response = Http::acceptJson() 136 | ->withToken(config("laravel-multipay.paystack_secret_key")) 137 | ->get("curl https://api.paystack.co/terminal/{$terminalId}/event/{$paymentEventId}") 138 | ->json(); 139 | 140 | if (!$response['status']) { 141 | throw new \Exception("Could not push to terminal. " . json_encode($response)); 142 | } 143 | 144 | return $response['data']['id']; 145 | } 146 | 147 | /** 148 | * Create a customer. 149 | * 150 | * @param integer $modelId The user id 151 | * 152 | * @return array 153 | */ 154 | protected function createCustomer(string $model, int $modelId, string $email): Customer 155 | { 156 | $response = Http::acceptJson() 157 | ->withToken(config("laravel-multipay.paystack_secret_key")) 158 | ->post('https://api.paystack.co/customer', ['email' => $email]) 159 | ->json(); 160 | 161 | if (!$response['status']) { 162 | throw new \Exception("Could not create customer. " . json_encode($response)); 163 | } 164 | 165 | $customer = Customer::create([ 166 | 'model' => $model, 167 | 'model_id' => $modelId, 168 | 'customer_id' => $response['data']['customer_code'], 169 | ]); 170 | 171 | return $customer; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/Services/PaymentHandlers/Remita.php: -------------------------------------------------------------------------------- 1 | 'application/json', 44 | "Authorization" => $auth, 45 | ]; 46 | } 47 | 48 | public function proceedToPaymentGateway(Payment $payment, $redirect_or_callback_url, $getFormForTesting = true): mixed 49 | { 50 | try { 51 | $rrr = $this->getRrrToInitiatePayment($payment); 52 | 53 | $payment->processor_transaction_reference = $rrr; 54 | $payment->save(); 55 | 56 | return $this->sendUserToPaymentGateway($rrr); 57 | } catch (\Throwable $th) { 58 | return PaymentService::redirectWithError($payment, [$th->getMessage()]); 59 | } 60 | } 61 | 62 | protected function getRrrToInitiatePayment(Payment $payment): string 63 | { 64 | $merchantId = config('laravel-multipay.remita_merchant_id'); 65 | $serviceTypeId = $this->getServiceTypeId($payment); 66 | $orderId = $payment->transaction_reference; 67 | $totalAmount = $payment->original_amount_displayed_to_user; 68 | $apiKey = config('laravel-multipay.remita_api_key'); 69 | $hash = hash("sha512", "{$merchantId}{$serviceTypeId}{$orderId}{$totalAmount}{$apiKey}"); 70 | $endpoint = $this->getBaseUrl() . "/exapp/api/v1/send/api/echannelsvc/merchant/api/paymentinit"; 71 | $requestHeaders = $this->getHttpRequestHeaders($merchantId, $hash); 72 | 73 | $postData = [ 74 | "serviceTypeId" => $serviceTypeId, 75 | "amount" => $totalAmount, 76 | "orderId" => $orderId, 77 | "payerName" => $payment->getPayerName(), 78 | "payerEmail" => $payment->getPayerEmail(), 79 | "payerPhone" => $payment->getPayerPhone(), 80 | "description" => $payment->transaction_description, 81 | ]; 82 | 83 | $response = Http::withHeaders($requestHeaders)->post($endpoint, $postData); 84 | 85 | throw_if(!$response->successful(), "Remita could not process your transaction at the moment. Please try again later. " . $response->body()); 86 | 87 | $responseJson = $response->json(); 88 | 89 | throw_if(!array_key_exists('RRR', $responseJson), "An error occurred while generating your RRR. Please try again later. " . $response->body()); 90 | 91 | return $responseJson['RRR']; 92 | } 93 | 94 | public function confirmResponseCanBeHandledAndUpdateDatabaseWithTransactionOutcome(Request $paymentGatewayServerResponse): ?Payment 95 | { 96 | if (!$paymentGatewayServerResponse->has('RRR')) { 97 | return null; 98 | } 99 | 100 | $rrr = $paymentGatewayServerResponse->RRR; 101 | 102 | $payment = Payment::where('processor_transaction_reference', $rrr) 103 | ->first(); 104 | 105 | if (is_null($payment)) { 106 | return null; 107 | } 108 | 109 | if ($payment->payment_processor_name != $this->getUniquePaymentHandlerName()) { 110 | return null; 111 | } 112 | 113 | $rrrQueryResponse = $this->queryRrr($rrr); 114 | 115 | return $this->useResponseToUpdatePayment($payment, RemitaResponse::from($rrrQueryResponse)); 116 | } 117 | 118 | protected function queryRrr($rrr): \stdClass 119 | { 120 | $merchantId = config('laravel-multipay.remita_merchant_id'); 121 | $apiKey = config('laravel-multipay.remita_api_key'); 122 | $hash = hash("sha512", "{$rrr}{$apiKey}{$merchantId}"); 123 | $requestHeaders = $this->getHttpRequestHeaders($merchantId, $hash); 124 | 125 | $statusUrl = $this->getBaseUrl() . "/exapp/api/v1/send/api/echannelsvc/{$merchantId}/{$rrr}/{$hash}/status.reg"; 126 | 127 | $response = Http::withHeaders($requestHeaders) 128 | ->get($statusUrl); 129 | 130 | throw_if( 131 | !$response->successful(), 132 | "Remita could not get transaction details at the moment. Please try again later. " . $response->body() 133 | ); 134 | 135 | return json_decode($response->body()); 136 | } 137 | 138 | public function reQuery(Payment $existingPayment): ?ReQuery 139 | { 140 | if ($existingPayment->payment_processor_name != $this->getUniquePaymentHandlerName()) { 141 | throw new WrongPaymentHandlerException($this, $existingPayment); 142 | } 143 | 144 | if (empty($existingPayment->processor_transaction_reference)) { 145 | return null; 146 | } 147 | 148 | $rrr = $existingPayment->processor_transaction_reference; 149 | 150 | $payment = Payment::where('processor_transaction_reference', $rrr) 151 | ->first(); 152 | 153 | throw_if(is_null($payment), "Could not reconcile Remita RRR with provided transaction"); 154 | 155 | $rrrQueryResponse = $this->queryRrr($rrr); 156 | 157 | $payment = $this->useResponseToUpdatePayment($payment, RemitaResponse::from($rrrQueryResponse)); 158 | 159 | return new ReQuery( 160 | payment: $payment, 161 | responseDetails: (array)$rrrQueryResponse, 162 | ); 163 | } 164 | 165 | /** 166 | * @see \Damms005\LaravelMultipay\Contracts\PaymentHandlerInterface::handleExternalWebhookRequest 167 | */ 168 | public function handleExternalWebhookRequest(Request $request): Payment 169 | { 170 | if (!$request->filled('rrr')) { 171 | throw new UnknownWebhookException($this); 172 | } 173 | 174 | $rrr = $request->rrr; 175 | 176 | $rrrQueryResponse = $this->queryRrr($rrr); 177 | 178 | if (!property_exists($rrrQueryResponse, "status")) { 179 | throw new NonActionableWebhookPaymentException($this, "No 'status' property in Remita server response", $request); 180 | } 181 | 182 | $payment = $this->getPaymentByRrr($rrr); 183 | 184 | if (is_null($payment)) { 185 | throw new NonActionableWebhookPaymentException($this, "Cannot fetch payment using RRR", $request); 186 | } 187 | 188 | $user = $this->getUserByEmail($rrrQueryResponse->email); 189 | 190 | if (is_null($user)) { 191 | throw new MissingUserException($this, "Cannot get user by email. Email was {$rrrQueryResponse->email}"); 192 | } 193 | 194 | $payment = $this->useResponseToUpdatePayment($payment, RemitaResponse::from($rrrQueryResponse)); 195 | 196 | return $payment; 197 | } 198 | 199 | protected function createNewPayment(User $user, \stdClass $responseBody): Payment 200 | { 201 | return (new CreateNewPayment())->execute( 202 | $this->getUniquePaymentHandlerName(), 203 | $user->id, 204 | null, 205 | Str::random(10), 206 | 'NGN', 207 | $responseBody->description, 208 | $responseBody->amount, 209 | [] 210 | ); 211 | } 212 | 213 | protected function getUserByEmail($email) 214 | { 215 | return User::whereEmail($email)->first(); 216 | } 217 | 218 | protected function getPaymentByRrr($rrr) 219 | { 220 | return Payment::where('processor_transaction_reference', $rrr) 221 | ->first(); 222 | } 223 | 224 | protected function useResponseToUpdatePayment(Payment $payment, RemitaResponse $rrrQueryResponse): Payment 225 | { 226 | $payment->processor_returned_response_description = json_encode($rrrQueryResponse); 227 | 228 | if (isset($rrrQueryResponse->paymentDate)) { 229 | $payment->processor_returned_transaction_date = Carbon::parse($rrrQueryResponse->paymentDate); 230 | } 231 | 232 | $payment->is_success = $rrrQueryResponse->status == "00"; 233 | 234 | // To re-query Remita transactions, users usually depend on the nullity is_success, such that 235 | // if it is NULL (its original/default value), the user knows it is eligible to be retried. Since we 236 | // cannot dependably rely on Remita to always push status of successful transactions (especially bank transactions), 237 | // users usually re-query Remita at intervals. We should therefore not set is_success prematurely. We should set it only 238 | // when we are sure that user cannot reasonably 239 | if ($this->isTransactionCanStillBeReQueried($rrrQueryResponse->status)) { 240 | $payment->is_success = null; 241 | } 242 | 243 | if ($payment->is_success) { 244 | $payment->processor_returned_amount = $rrrQueryResponse->amount; 245 | } 246 | 247 | $payment->save(); 248 | $payment->refresh(); 249 | 250 | return $payment; 251 | } 252 | 253 | protected function isTransactionCanStillBeReQueried(string $paymentStatus) 254 | { 255 | return in_array($paymentStatus, $this->responseCodesIndicatingUnFulfilledTransactionState); 256 | } 257 | 258 | public function getHumanReadableTransactionResponse(Payment $payment): string 259 | { 260 | return ''; 261 | } 262 | 263 | public function convertResponseCodeToHumanReadable($responseCode): string 264 | { 265 | return $responseCode; 266 | } 267 | 268 | protected function convertAmountToValueRequiredByPaystack($original_amount_displayed_to_user) 269 | { 270 | return $original_amount_displayed_to_user * 100; //paystack only accept amount in kobo/lowest denomination of target currency 271 | } 272 | 273 | protected function sendUserToPaymentGateway(string $rrr) 274 | { 275 | $url = $this->getBaseUrl() . "/ecomm/finalize.reg"; 276 | $merchantId = config('laravel-multipay.remita_merchant_id'); 277 | $apiKey = config('laravel-multipay.remita_api_key'); 278 | $hash = hash("sha512", "{$merchantId}{$rrr}{$apiKey}"); 279 | $responseUrl = route('payment.finished.callback_url'); 280 | 281 | return view('laravel-multipay::payment-handler-specific.remita-auto_submitted_form', [ 282 | 'url' => $url, 283 | 'rrr' => $rrr, 284 | 'hash' => $hash, 285 | 'merchantId' => $merchantId, 286 | 'responseUrl' => $responseUrl, 287 | ]); 288 | } 289 | 290 | public function getServiceTypeId(Payment $payment) 291 | { 292 | // Prioritize user-defined service id 293 | if (Arr::has($payment->metadata, 'remita_service_id')) { 294 | return Arr::get($payment->metadata, 'remita_service_id'); 295 | } else { 296 | throw new \Exception('Missing Remita service id. Please specify the Remita service id in the payment metadata json.'); 297 | } 298 | } 299 | 300 | public function getBaseUrl() 301 | { 302 | return config('laravel-multipay.remita_base_request_url'); 303 | } 304 | 305 | public function getTransactionReferenceName(): string 306 | { 307 | return 'RRR Code'; 308 | } 309 | 310 | public function paymentIsUnsettled(Payment $payment): bool 311 | { 312 | if (is_null($payment->processor_returned_response_description)) { 313 | return true; 314 | } 315 | 316 | $returnedResponse = json_decode($payment->processor_returned_response_description, true); 317 | 318 | if (!$returnedResponse) { 319 | return true; 320 | } 321 | 322 | if ($this->isTransactionCanStillBeReQueried($returnedResponse['status'])) { 323 | return true; 324 | } 325 | 326 | $internalErrorOccurred = $returnedResponse['status'] == '998'; 327 | 328 | if ($internalErrorOccurred) { 329 | return true; 330 | } 331 | 332 | return false; 333 | } 334 | 335 | public function resumeUnsettledPayment(Payment $payment): mixed 336 | { 337 | if (!$payment->processor_transaction_reference) { 338 | throw new \Exception("Attempt was made to resume a payment that does not have RRR. Payment id is {$payment->id}"); 339 | } 340 | 341 | return $this->sendUserToPaymentGateway($payment->processor_transaction_reference); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/Services/PaymentHandlers/UnifiedPayments.php: -------------------------------------------------------------------------------- 1 | 'application/json', 23 | ]) 24 | ->post(self::UP_SERVER_URL . "/KOLBINS", [ 25 | "amount" => $payment->original_amount_displayed_to_user, 26 | "currency" => "566", 27 | "description" => "{$payment->transaction_description}. (IP: " . request()->ip() . ")", 28 | "returnUrl" => $redirect_or_callback_url, 29 | "secretKey" => self::UP_SECRET_KEY, 30 | "fee" => 0, 31 | ]); 32 | 33 | if (!$response->successful()) { 34 | return PaymentService::redirectWithError($payment, ["Unified Payments could not process your transaction at the moment. Please try again later. " . $response->body()]); 35 | } 36 | 37 | $transactionId = $response->body(); 38 | 39 | $payment->processor_transaction_reference = $transactionId; 40 | $payment->save(); 41 | 42 | return $this->sendUserToPaymentGateway(self::UP_SERVER_URL . "/{$transactionId}"); 43 | } 44 | 45 | /** 46 | * 47 | * @param Request $paymentGatewayServerResponse 48 | * 49 | * @return Payment 50 | */ 51 | public function confirmResponseCanBeHandledAndUpdateDatabaseWithTransactionOutcome(Request $paymentGatewayServerResponse): ?Payment 52 | { 53 | if (!$paymentGatewayServerResponse->has('trxId')) { 54 | return null; 55 | } 56 | 57 | $payment = Payment::where('processor_transaction_reference', $paymentGatewayServerResponse->trxId)->first(); 58 | 59 | if (is_null($payment)) { 60 | return null; 61 | } 62 | 63 | if ($payment->payment_processor_name != $this->getUniquePaymentHandlerName()) { 64 | return null; 65 | } 66 | 67 | $response = Http::get(self::UP_SERVER_URL . "/Status/{$payment->processor_transaction_reference}"); 68 | 69 | throw_if(!$response->successful(), "Could not validate Unified Payment transaction"); 70 | 71 | $responseBody = json_decode($response->body()); 72 | 73 | $payment->processor_returned_response_description = $response->body(); 74 | 75 | if (isset($responseBody->TranDateTime)) { 76 | $payment->processor_returned_transaction_date = Carbon::createFromFormat('d/m/Y H:i:s', $responseBody->TranDateTime); 77 | } 78 | 79 | $payment->processor_returned_amount = $responseBody->Amount; 80 | $payment->is_success = $responseBody->Status == "APPROVED"; 81 | 82 | $payment->save(); 83 | $payment->refresh(); 84 | 85 | return $payment; 86 | } 87 | 88 | public function reQuery(Payment $existingPayment): ?ReQuery 89 | { 90 | throw new \Exception("Method not yet implemented"); 91 | } 92 | 93 | /** 94 | * @see \Damms005\LaravelMultipay\Contracts\PaymentHandlerInterface::handleExternalWebhookRequest 95 | */ 96 | public function handleExternalWebhookRequest(Request $request): Payment 97 | { 98 | throw new UnknownWebhookException($this); 99 | } 100 | 101 | public function getHumanReadableTransactionResponse(Payment $payment): string 102 | { 103 | return ''; 104 | } 105 | 106 | public function convertResponseCodeToHumanReadable($responseCode): string 107 | { 108 | return $responseCode; 109 | } 110 | 111 | protected function convertAmountToValueRequiredByPaystack($original_amount_displayed_to_user) 112 | { 113 | return $original_amount_displayed_to_user * 100; //paystack only accept amount in kobo/lowest denomination of target currency 114 | } 115 | 116 | protected function sendUserToPaymentGateway($unified_payment_redirect_url) 117 | { 118 | return redirect()->away($unified_payment_redirect_url); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Services/PaymentService.php: -------------------------------------------------------------------------------- 1 | make(BasePaymentHandler::class); 18 | 19 | return $basePaymentHandler->storePaymentAndShowUserBeforeProcessing($user_id, $amount, $description, $currency, $transaction_reference, null, null, $view, $metadata); 20 | } 21 | 22 | public static function getHandlerFqcn(string $uniqueName): BasePaymentHandler 23 | { 24 | $handlers = BasePaymentHandler::getNamesOfPaymentHandlers()->filter(fn($name) => $name === $uniqueName); 25 | 26 | if ($handlers->isEmpty()) { 27 | throw new \Exception("No handler found with name '{$uniqueName}'"); 28 | } 29 | 30 | if ($handlers->count() !== 1) { 31 | throw new \Exception("Multiple handler found with name '{$uniqueName}'"); 32 | } 33 | 34 | return $handlers 35 | ->map(fn($name, $key) => new $key()) 36 | ->sole(); 37 | } 38 | 39 | public function getPaymentHandlerByName(string $paymentHandlerName): PaymentHandlerInterface 40 | { 41 | try { 42 | $handlerFqcn = "\\Damms005\\LaravelMultipay\\Services\\PaymentHandlers\\{$paymentHandlerName}"; 43 | 44 | /** @var PaymentHandlerInterface */ 45 | $paymentHandler = new $handlerFqcn(); 46 | 47 | return $paymentHandler; 48 | } catch (\Throwable $th) { 49 | throw new \Exception("Could not get payment processor: {$paymentHandlerName}"); 50 | } 51 | } 52 | 53 | public function handleGatewayResponse(Request $paymentGatewayServerResponse, string $paymentHandlerName): ?Payment 54 | { 55 | $paymentHandler = $this->getPaymentHandlerByName($paymentHandlerName); 56 | 57 | return $paymentHandler->confirmResponseCanBeHandledAndUpdateDatabaseWithTransactionOutcome($paymentGatewayServerResponse); 58 | } 59 | 60 | public static function redirectWithError(Payment $payment, array $error) 61 | { 62 | return redirect($payment->metadata['completion_url'] ?? '/')->withErrors($error); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Services/SubscriptionService.php: -------------------------------------------------------------------------------- 1 | createPaymentPlan($name, $amount, $interval, $description, $currency); 16 | 17 | $plan = PaymentPlan::create([ 18 | 'name' => $name, 19 | 'amount' => $amount, 20 | 'interval' => $interval, 21 | 'description' => $description, 22 | 'currency' => $currency, 23 | 'payment_handler_fqcn' => $handler->getUniquePaymentHandlerName(), 24 | 'payment_handler_plan_id' => $planId, 25 | ]); 26 | 27 | return $plan; 28 | } 29 | 30 | public static function subscribeToPlan(PaymentHandlerInterface $handler, User $user, PaymentPlan $plan, string $completionUrl) 31 | { 32 | $transactionReference = strtoupper(str()->random()); 33 | 34 | $url = $handler->subscribeToPlan($user, $plan, $transactionReference); 35 | 36 | (new CreateNewPayment())->execute( 37 | $handler->getUniquePaymentHandlerName(), 38 | $user->id, 39 | $completionUrl, 40 | $transactionReference, 41 | $plan->currency, 42 | $plan->description, 43 | $plan->amount, 44 | ['payment_plan_id' => $plan->id] 45 | ); 46 | 47 | return redirect()->away($url); 48 | } 49 | 50 | public static function getActiveSubscriptionFor(User $user, PaymentPlan $plan): ?Subscription 51 | { 52 | return Subscription::where('user_id', $user->id) 53 | ->where('payment_plan_id', $plan->id) 54 | ->where('next_payment_due_date', '>', now()) 55 | ->first(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ValueObjects/PaystackVerificationResponse.php: -------------------------------------------------------------------------------- 1 | status ?? null, 24 | message: $paystackResponse->message ?? null, 25 | data: collect($paystackResponse->data ?? null) 26 | ->only(['status', 'amount', 'gateway_response', 'created_at', 'metadata']) 27 | ->toArray(), 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ValueObjects/ReQuery.php: -------------------------------------------------------------------------------- 1 | paymentDate ?? null, 22 | status: $rrrQueryResponse->status ?? null, 23 | amount: $rrrQueryResponse->amount ?? null, 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Webhooks/Contracts/WebhookHandler.php: -------------------------------------------------------------------------------- 1 | input('event') === 'charge.success'; 24 | } 25 | 26 | public function handle(Request $webhookRequest): Payment 27 | { 28 | $payment = Payment::where('transaction_reference', $webhookRequest->input('data.reference')) 29 | ->orWhere('transaction_reference', $webhookRequest->input('data.metadata.reference')) 30 | ->first(); 31 | 32 | if (!$payment) { 33 | throw new PaymentNotFoundException($webhookRequest, get_class(app(PaymentHandlerInterface::class)) . ' - Payment not found in Paystack\'s charge.success event. Payload: ' . json_encode($webhookRequest->all())); 34 | } 35 | 36 | $metadata = [...$payment->metadata ?? []]; 37 | $metadata = Arr::set($metadata, 'events', $metadata['events'] ?? []); 38 | $metadata['events']['charge.success'] = $webhookRequest->all(); 39 | 40 | $payment->update(['metadata' => $metadata]); 41 | 42 | return (new Paystack())->processValueForTransaction($webhookRequest->input('data.reference')); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Webhooks/Paystack/InvoicePaymentFailed.php: -------------------------------------------------------------------------------- 1 | 7 | @media print { 8 | 9 | .header, 10 | .footer, 11 | .page-titles, 12 | .left-sidebar { 13 | display: none; 14 | } 15 | } 16 | 17 |
18 |

19 | {{ $instructions }} 20 |

21 | 22 |
23 | Description: {{ $payment->transaction_description }} 24 |
25 | 26 |
27 | payer: {{ $payment->getPayerName() }} 28 |
29 | 30 |
31 | Transaction Reference: {{ $payment->transaction_reference }} 32 |
33 | 34 |
35 | Amount: {{ $currency }} {{ number_format($payment->original_amount_displayed_to_user) }} 36 |
37 | 38 |
39 | @if (config('laravel-multipay.enable_payment_confirmation_page_print')) 40 | 41 | @endif 42 | 43 |
44 | @csrf 45 | 46 | 54 |
55 | 56 |
57 | @csrf 58 |
59 | 60 |
61 |
62 |
63 |
64 | 65 | @if ($payment->payment_processor_name == $unifiedPaymentName) 66 |
67 | UPayments logo 68 | PayAttitude Pay with Phone number, VbV, MasterCard Secure Code 69 |
70 | "Service Provided by Unified Payment Services Limited" 71 |
72 |
73 | @endif 74 | 75 | @endsection 76 | -------------------------------------------------------------------------------- /views/generic-auto-submitted-payment-form.blade.php: -------------------------------------------------------------------------------- 1 |
2 | @csrf 3 | 4 | 5 | 6 | 7 |
8 | 9 | -------------------------------------------------------------------------------- /views/generic-confirm_transaction.blade.php: -------------------------------------------------------------------------------- 1 | @extends(config('laravel-multipay.extended_layout')) 2 | @section('title', 'Payment Confirmation') 3 | 4 | @section(config('laravel-multipay.section_name')) 5 | 16 |
17 |

18 | {{ $instructions }} 19 |

20 | 21 |
22 | Description: {{ $payment->transaction_description }} 23 |
24 | 25 |
26 | Transaction Reference: {{ $payment->transaction_reference }} 27 |
28 | 29 |
30 | Amount: {{ $currency }} {{ number_format($payment->original_amount_displayed_to_user) }} 31 |
32 | 33 |
34 | @if (config('laravel-multipay.enable_payment_confirmation_page_print')) 35 | 36 | @endif 37 | 38 |
39 | @csrf 40 | 41 | 52 |
53 |
54 |
55 | 56 | @endsection 57 | -------------------------------------------------------------------------------- /views/partials/payment-summary-generic.blade.php: -------------------------------------------------------------------------------- 1 |
2 | Description: {{ $payment->transaction_description }} 3 |
4 | 5 |
6 | Amount: {{ $payment->transaction_currency }}{{ number_format( $payment->original_amount_displayed_to_user ) }} 7 |
8 | 9 |
10 | Reference number: {{ $payment->transaction_reference }} 11 |
-------------------------------------------------------------------------------- /views/partials/payment-summary-json.blade.php: -------------------------------------------------------------------------------- 1 | @foreach ($paymentDescription as $paymentDescriptionName => $paymentDescriptionItem) 2 |
3 | 4 | {{$paymentDescriptionName}}: 5 | 6 | 7 | @if(is_array($paymentDescriptionItem)) 8 | @foreach ($paymentDescriptionItem as $key => $value) 9 | {{$value}} 10 | @endforeach 11 | @else 12 | {{$paymentDescriptionItem}} 13 | @endif 14 |
15 | @endforeach 16 | -------------------------------------------------------------------------------- /views/payment-handler-specific/interswitch-form.blade.php: -------------------------------------------------------------------------------- 1 | @extends('master' , ['bannerImage' => 'images/hour-glass-full.jpg']) 2 | @section('title' , 'Payment') 3 | @section('pageName' , 'Payment') 4 | 5 | @section('pageContainerContent') 6 | 7 |
8 | 9 | Amount: NGN{{number_format($amount_in_naira)}} 10 |

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |

19 | Dear {{$user?->name ?? 'user'}}, please note your unique transaction reference: {{$txn_ref}} 20 |

21 |
22 |

23 | Please keep this number, as you may need it in case you need to refer to this transaction. 24 |

25 |
26 | 30 | 31 |
32 | 33 | @endsection 34 | -------------------------------------------------------------------------------- /views/payment-handler-specific/paystack-auto_submitted_form.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | {{ csrf_field() }} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Loading... 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /views/payment-handler-specific/remita-auto_submitted_form.blade.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Loading... 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /views/test-drive/pay.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Laravel Multipay Test-drive Page 🏎️ 9 | 10 | 27 | 28 | 29 | 30 | 31 |
32 | 33 | @csrf 34 | 35 |
36 | 37 | Payment handler (Good UX tip 👍: this should be a hidden field) 38 |
39 | 44 |
45 |
46 |
47 | 48 |
49 | Amount 50 |
51 | 52 |
53 |
54 |
55 | 56 |
57 | Currency (ISO-4217 format. Ensure to check that the payment handler you specified above supports this currency) 58 |
59 | 60 |
61 |
62 |
63 | 64 |
65 | 66 |
67 |
68 | 69 |
70 | Description 71 |
72 |
73 | 74 |
75 | 76 |
77 |
78 |
79 | 80 | 81 |
82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /views/test/layout.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @yield('content') 5 | 6 | 7 | -------------------------------------------------------------------------------- /views/transaction-completed.blade.php: -------------------------------------------------------------------------------- 1 | @extends(config('laravel-multipay.extended_layout')) 2 | @section('title', 'Transaction Summary') 3 | 4 | @section('content') 5 | 6 | 17 |
18 | @if ( !is_null( $payment ) ) 19 | 20 | Dear {{ $payment->user?->name ?? 'user' }}, your transaction with reference number {{ $payment->transaction_reference }} 21 | 22 | @if ($payment->is_success == 1) 23 | 24 | was successful. 25 | 26 | @if ($isJsonDescription) 27 | @include('laravel-multipay::partials.payment-summary-json') 28 | @else 29 | @include('laravel-multipay::partials.payment-summary-generic') 30 | @endif 31 | 32 | 33 |
34 | 35 |
36 | 37 | @else 38 | 39 | was not successful. 40 | 41 |
42 | Reason: 43 |
44 | 			{{ $payment->processor_returned_response_description }}
45 | 		
46 | 47 |
48 | 49 | @endif 50 | 51 | @if ($payment->completion_url) 52 | 53 | 54 | Click here to continue 55 | 56 | 57 | @endif 58 | 59 | 60 | @else 61 | 62 |
63 | 64 | Error: could not process transaction response. 65 | 66 |
67 | 68 | @endif 69 |
70 | @endsection 71 | --------------------------------------------------------------------------------