diff --git a/app/Models/Gateway.php b/app/Models/Gateway.php index 6b6399dc6e20..5d5a5578b920 100644 --- a/app/Models/Gateway.php +++ b/app/Models/Gateway.php @@ -137,6 +137,11 @@ class Gateway extends StaticModel GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true] // GoCardless ]; break; + case 58: + return [ + GatewayType::HOSTED_PAGE => ['refund' => false, 'token_billing' => false] // Razorpay + ]; + break; default: return []; break; diff --git a/app/Models/GatewayType.php b/app/Models/GatewayType.php index e0b0fbfdcd35..7a77a56e5142 100644 --- a/app/Models/GatewayType.php +++ b/app/Models/GatewayType.php @@ -28,6 +28,7 @@ class GatewayType extends StaticModel const KBC = 11; const BANCONTACT = 12; const IDEAL = 13; + const HOSTED_PAGE = 14; // For gateways that contain multiple methods. public function gateway() { @@ -66,6 +67,8 @@ class GatewayType extends StaticModel return ctrans('texts.bancontact'); case self::IDEAL: return ctrans('texts.ideal'); + case self::HOSTED_PAGE: + return ctrans('texts.aio_checkout'); default: return 'Undefined.'; break; diff --git a/app/Models/PaymentType.php b/app/Models/PaymentType.php index ff396181578e..faaeb9052f0a 100644 --- a/app/Models/PaymentType.php +++ b/app/Models/PaymentType.php @@ -46,6 +46,7 @@ class PaymentType extends StaticModel const KBC = 35; const BANCONTACT = 36; const IDEAL = 37; + const HOSTED_PAGE = 38; public static function parseCardType($cardName) { diff --git a/app/Models/SystemLog.php b/app/Models/SystemLog.php index 316ada10063b..2565c4d003b4 100644 --- a/app/Models/SystemLog.php +++ b/app/Models/SystemLog.php @@ -74,6 +74,7 @@ class SystemLog extends Model const TYPE_EWAY = 313; const TYPE_SQUARE = 320; const TYPE_GOCARDLESS = 321; + const TYPE_RAZORPAY = 322; const TYPE_QUOTA_EXCEEDED = 400; const TYPE_UPSTREAM_FAILURE = 401; diff --git a/app/PaymentDrivers/Razorpay/Hosted.php b/app/PaymentDrivers/Razorpay/Hosted.php new file mode 100644 index 000000000000..736a31c4e762 --- /dev/null +++ b/app/PaymentDrivers/Razorpay/Hosted.php @@ -0,0 +1,184 @@ +razorpay = $razorpay; + + $this->razorpay->init(); + } + + /** + * Show the authorization page for Razorpay. + * + * @param array $data + * @return View + */ + public function authorizeView(array $data): View + { + return render('gateways.razorpay.hosted.authorize', $data); + } + + /** + * Handle the authorization page for Razorpay. + * + * @param Request $request + * @return RedirectResponse + */ + public function authorizeResponse(Request $request): RedirectResponse + { + return redirect()->route('client.payment_methods.index'); + } + + /** + * Payment view for the Razorpay. + * + * @param array $data + * @return View + */ + public function paymentView(array $data): View + { + $order = $this->razorpay->gateway->order->create([ + 'currency' => $this->razorpay->client->currency()->code, + 'amount' => $this->razorpay->convertToRazorpayAmount((float) $this->razorpay->payment_hash->data->amount_with_fee), + ]); + + $this->razorpay->payment_hash->withData('order_id', $order->id); + $this->razorpay->payment_hash->withData('order_amount', $order->amount); + + $data['gateway'] = $this->razorpay; + + $data['options'] = [ + 'key' => $this->razorpay->company_gateway->getConfigField('apiKey'), + 'amount' => $this->razorpay->convertToRazorpayAmount((float) $this->razorpay->payment_hash->data->amount_with_fee), + 'currency' => $this->razorpay->client->currency()->code, + 'name' => $this->razorpay->company_gateway->company->present()->name(), + 'order_id' => $order->id, + ]; + + return render('gateways.razorpay.hosted.pay', $data); + } + + /** + * Handle payments page for Razorpay. + * + * @param PaymentResponseRequest $request + * @return void + */ + public function paymentResponse(PaymentResponseRequest $request) + { + $request->validate([ + 'payment_hash' => ['required'], + 'razorpay_payment_id' => ['required'], + 'razorpay_signature' => ['required'], + ]); + + if (! property_exists($this->razorpay->payment_hash->data, 'order_id')) { + throw new PaymentFailed('Missing [order_id] property. Please contact the administrator. Reference: ' . $this->razorpay->payment_hash->hash); + } + + try { + $attributes = [ + 'razorpay_order_id' => $this->razorpay->payment_hash->data->order_id, + 'razorpay_payment_id' => $request->razorpay_payment_id, + 'razorpay_signature' => $request->razorpay_signature, + ]; + + $this->razorpay->gateway->utility->verifyPaymentSignature($attributes); + + return $this->processSuccessfulPayment($request->razorpay_payment_id); + } + catch (SignatureVerificationError $exception) { + return $this->processUnsuccessfulPayment($exception); + } + } + + /** + * Handle the successful payment for Razorpay. + * + * @param string $payment_id + * @return RedirectResponse + */ + public function processSuccessfulPayment(string $payment_id): RedirectResponse + { + $data = [ + 'gateway_type_id' => GatewayType::HOSTED_PAGE, + 'amount' => array_sum(array_column($this->razorpay->payment_hash->invoices(), 'amount')) + $this->razorpay->payment_hash->fee_total, + 'payment_type' => PaymentType::HOSTED_PAGE, + 'transaction_reference' => $payment_id, + ]; + + $payment_record = $this->razorpay->createPayment($data, Payment::STATUS_COMPLETED); + + SystemLogger::dispatch( + ['response' => $payment_id, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_RAZORPAY, + $this->razorpay->client, + $this->razorpay->client->company, + ); + + return redirect()->route('client.payments.show', ['payment' => $this->razorpay->encodePrimaryKey($payment_record->id)]); + } + + /** + * Handle unsuccessful payment for Razorpay. + * + * @param Exception $exception + * @throws PaymentFailed + * @return void + */ + public function processUnsuccessfulPayment(\Exception $exception): void + { + PaymentFailureMailer::dispatch( + $this->razorpay->client, + $exception->getMessage(), + $this->razorpay->client->company, + $this->razorpay->payment_hash->data->amount_with_fee + ); + + SystemLogger::dispatch( + $exception->getMessage(), + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_RAZORPAY, + $this->razorpay->client, + $this->razorpay->client->company, + ); + + throw new PaymentFailed($exception->getMessage(), $exception->getCode()); + } +} diff --git a/app/PaymentDrivers/RazorpayPaymentDriver.php b/app/PaymentDrivers/RazorpayPaymentDriver.php new file mode 100644 index 000000000000..927ca27805d4 --- /dev/null +++ b/app/PaymentDrivers/RazorpayPaymentDriver.php @@ -0,0 +1,102 @@ + Hosted::class, + ]; + + const SYSTEM_LOG_TYPE = SystemLog::TYPE_RAZORPAY; + + public function init(): self + { + $this->gateway = new \Razorpay\Api\Api( + $this->company_gateway->getConfigField('apiKey'), + $this->company_gateway->getConfigField('apiSecret'), + ); + + return $this; + } + + public function gatewayTypes(): array + { + return [ + GatewayType::HOSTED_PAGE, + ]; + } + + public function setPaymentMethod($payment_method_id) + { + $class = self::$methods[$payment_method_id]; + + $this->payment_method = new $class($this); + + return $this; + } + + public function authorizeView(array $data) + { + return $this->payment_method->authorizeView($data); + } + + public function authorizeResponse($request) + { + return $this->payment_method->authorizeResponse($request); + } + + public function processPaymentView(array $data) + { + return $this->payment_method->paymentView($data); + } + + public function processPaymentResponse($request) + { + return $this->payment_method->paymentResponse($request); + } + + public function refund(Payment $payment, $amount, $return_client_response = false) {} + + public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash) {} + + /** + * Convert the amount to the format that Razorpay supports. + * + * @param mixed|float $amount + * @return int + */ + public function convertToRazorpayAmount($amount): int + { + return \number_format((float) $amount * 100, 0, '.', ''); + } +} diff --git a/composer.json b/composer.json index 9e3eee567db6..f981ecbe61c4 100644 --- a/composer.json +++ b/composer.json @@ -72,6 +72,7 @@ "payfast/payfast-php-sdk": "^1.1", "pragmarx/google2fa": "^8.0", "predis/predis": "^1.1", + "razorpay/razorpay": "2.*", "sentry/sentry-laravel": "^2", "square/square": "13.0.0.20210721", "stripe/stripe-php": "^7.50", diff --git a/composer.lock b/composer.lock index cd3141490d06..1f7570d4f7e8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "96908a391244cbc96eefbb130bd7bed9", + "content-hash": "dc4f3d21b0f54361b6d4b85674fc900e", "packages": [ { "name": "apimatic/jsonmapper", @@ -7789,6 +7789,68 @@ ], "time": "2021-09-25T23:10:38+00:00" }, + { + "name": "razorpay/razorpay", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/razorpay/razorpay-php.git", + "reference": "f562c919d153c343428c9a4e8d4e0848f334aef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/razorpay/razorpay-php/zipball/f562c919d153c343428c9a4e8d4e0848f334aef4", + "reference": "f562c919d153c343428c9a4e8d4e0848f334aef4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.3.0", + "rmccue/requests": "v1.8.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.8|~5.0", + "raveren/kint": "1.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Razorpay\\Api\\": "src/", + "Razorpay\\Tests\\": "tests/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Abhay Rana", + "email": "nemo@razorpay.com", + "homepage": "https://captnemo.in", + "role": "Developer" + }, + { + "name": "Shashank Kumar", + "email": "shashank@razorpay.com", + "role": "Developer" + } + ], + "description": "Razorpay PHP Client Library", + "homepage": "https://docs.razorpay.com", + "keywords": [ + "api", + "client", + "php", + "razorpay" + ], + "support": { + "email": "contact@razorpay.com", + "issues": "https://github.com/Razorpay/razorpay-php/issues", + "source": "https://github.com/Razorpay/razorpay-php" + }, + "time": "2021-09-16T06:18:12+00:00" + }, { "name": "react/promise", "version": "v2.8.0", @@ -7839,6 +7901,66 @@ }, "time": "2020-05-12T15:16:56+00:00" }, + { + "name": "rmccue/requests", + "version": "v1.8.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/Requests.git", + "reference": "afbe4790e4def03581c4a0963a1e8aa01f6030f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/Requests/zipball/afbe4790e4def03581c4a0963a1e8aa01f6030f1", + "reference": "afbe4790e4def03581c4a0963a1e8aa01f6030f1", + "shasum": "" + }, + "require": { + "php": ">=5.2" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.7", + "php-parallel-lint/php-console-highlighter": "^0.5.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpcompatibility/php-compatibility": "^9.0", + "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5", + "requests/test-server": "dev-master", + "squizlabs/php_codesniffer": "^3.5", + "wp-coding-standards/wpcs": "^2.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Requests": "library/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "ISC" + ], + "authors": [ + { + "name": "Ryan McCue", + "homepage": "http://ryanmccue.info" + } + ], + "description": "A HTTP library written in PHP, for human beings.", + "homepage": "http://github.com/WordPress/Requests", + "keywords": [ + "curl", + "fsockopen", + "http", + "idna", + "ipv6", + "iri", + "sockets" + ], + "support": { + "issues": "https://github.com/WordPress/Requests/issues", + "source": "https://github.com/WordPress/Requests/tree/v1.8.0" + }, + "time": "2021-04-27T11:05:25+00:00" + }, { "name": "sabre/uri", "version": "2.2.1", @@ -15962,5 +16084,5 @@ "platform-dev": { "php": "^7.3|^7.4|^8.0" }, - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.1.0" } diff --git a/database/migrations/2021_10_07_141737_razorpay.php b/database/migrations/2021_10_07_141737_razorpay.php new file mode 100644 index 000000000000..6dcacd66d63e --- /dev/null +++ b/database/migrations/2021_10_07_141737_razorpay.php @@ -0,0 +1,33 @@ +id = 58; + $gateway->name = 'Razorpay'; + $gateway->key = 'hxd6gwg3ekb9tb3v9lptgx1mqyg69zu9'; + $gateway->provider = 'Razorpay'; + $gateway->is_offsite = false; + + $configuration = new \stdClass; + $configuration->apiKey = ''; + $configuration->apiSecret = ''; + + $gateway->fields = \json_encode($configuration); + $gateway->visible = true; + $gateway->site_url = 'https://razorpay.com'; + $gateway->default_gateway_type_id = GatewayType::HOSTED_PAGE; + $gateway->save(); + } +} diff --git a/database/migrations/2021_10_07_155410_add_hosted_page_to_payment_types.php b/database/migrations/2021_10_07_155410_add_hosted_page_to_payment_types.php new file mode 100644 index 000000000000..8aefe97af473 --- /dev/null +++ b/database/migrations/2021_10_07_155410_add_hosted_page_to_payment_types.php @@ -0,0 +1,24 @@ +id = 35; + $type->name = 'Hosted Page'; + $type->gateway_type_id = GatewayType::HOSTED_PAGE; + + $type->save(); + } +} diff --git a/database/seeders/PaymentLibrariesSeeder.php b/database/seeders/PaymentLibrariesSeeder.php index d512c31b7350..ab9431bbf0e5 100644 --- a/database/seeders/PaymentLibrariesSeeder.php +++ b/database/seeders/PaymentLibrariesSeeder.php @@ -81,6 +81,7 @@ class PaymentLibrariesSeeder extends Seeder ['id' => 54, 'name' => 'PAYMILL', 'provider' => 'Paymill', 'key' => 'ca52f618a39367a4c944098ebf977e1c', 'fields' => '{"apiKey":""}'], ['id' => 55, 'name' => 'Custom', 'provider' => 'Custom', 'is_offsite' => true, 'sort_order' => 21, 'key' => '54faab2ab6e3223dbe848b1686490baa', 'fields' => '{"name":"","text":""}'], ['id' => 57, 'name' => 'Square', 'provider' => 'Square', 'is_offsite' => false, 'sort_order' => 21, 'key' => '65faab2ab6e3223dbe848b1686490baz', 'fields' => '{"accessToken":"","applicationId":"","locationId":"","testMode":false}'], + ['id' => 58, 'name' => 'Razorpay', 'provider' => 'Razorpay', 'is_offsite' => false, 'sort_order' => 21, 'key' => 'hxd6gwg3ekb9tb3v9lptgx1mqyg69zu9', 'fields' => '{"apiKey":"","apiSecret":""}'], ]; foreach ($gateways as $gateway) { @@ -97,7 +98,7 @@ class PaymentLibrariesSeeder extends Seeder Gateway::query()->update(['visible' => 0]); - Gateway::whereIn('id', [1,7,11,15,20,39,46,55,50,57,52])->update(['visible' => 1]); + Gateway::whereIn('id', [1,7,11,15,20,39,46,55,50,57,52,58])->update(['visible' => 1]); if (Ninja::isHosted()) { Gateway::whereIn('id', [20])->update(['visible' => 0]); diff --git a/public/js/clients/payments/razorpay-aio.js b/public/js/clients/payments/razorpay-aio.js new file mode 100644 index 000000000000..dbf83802dd09 --- /dev/null +++ b/public/js/clients/payments/razorpay-aio.js @@ -0,0 +1,2 @@ +/*! For license information please see razorpay-aio.js.LICENSE.txt */ +!function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=27)}({27:function(e,t,n){e.exports=n("tIvh")},tIvh:function(e,t){var n,r=JSON.parse(null===(n=document.querySelector("meta[name=razorpay-options]"))||void 0===n?void 0:n.content);r.handler=function(e){document.getElementById("razorpay_payment_id").value=e.razorpay_payment_id,document.getElementById("razorpay_signature").value=e.razorpay_signature,document.getElementById("server-response").submit()};var o=new Razorpay(r);document.getElementById("pay-now").onclick=function(e){e.target.parentElement.disabled=!0,o.open()}}}); \ No newline at end of file diff --git a/public/js/clients/payments/razorpay-aio.js.LICENSE.txt b/public/js/clients/payments/razorpay-aio.js.LICENSE.txt new file mode 100644 index 000000000000..c357ff50e6ab --- /dev/null +++ b/public/js/clients/payments/razorpay-aio.js.LICENSE.txt @@ -0,0 +1,9 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://opensource.org/licenses/AAL + */ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index e52f5e227431..d6a844a098c8 100755 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -15,6 +15,7 @@ "/js/clients/payments/eway-credit-card.js": "/js/clients/payments/eway-credit-card.js?id=08ea84e9451abd434cff", "/js/clients/payments/mollie-credit-card.js": "/js/clients/payments/mollie-credit-card.js?id=73b66e88e2daabcd6549", "/js/clients/payments/paytrace-credit-card.js": "/js/clients/payments/paytrace-credit-card.js?id=c2b5f7831e1a46dd5fb2", + "/js/clients/payments/razorpay-aio.js": "/js/clients/payments/razorpay-aio.js?id=817ab3b2b94ee37b14eb", "/js/clients/payments/square-credit-card.js": "/js/clients/payments/square-credit-card.js?id=070c86b293b532c5a56c", "/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=81c2623fc1e5769b51c7", "/js/clients/payments/stripe-alipay.js": "/js/clients/payments/stripe-alipay.js?id=665ddf663500767f1a17", diff --git a/resources/js/clients/payments/razorpay-aio.js b/resources/js/clients/payments/razorpay-aio.js new file mode 100644 index 000000000000..2ae159a3e7f6 --- /dev/null +++ b/resources/js/clients/payments/razorpay-aio.js @@ -0,0 +1,29 @@ +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://opensource.org/licenses/AAL + */ + +let options = JSON.parse( + document.querySelector('meta[name=razorpay-options]')?.content +); + +options.handler = function(response) { + document.getElementById('razorpay_payment_id').value = + response.razorpay_payment_id; + document.getElementById('razorpay_signature').value = + response.razorpay_signature; + document.getElementById('server-response').submit(); +}; + +let razorpay = new Razorpay(options); + +document.getElementById('pay-now').onclick = function(event) { + event.target.parentElement.disabled = true; + + razorpay.open(); +}; diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index 2bffc05fda02..a79ec3208054 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -4317,6 +4317,7 @@ $LANG = array( 'kbc_cbc' => 'KBC/CBC', 'bancontact' => 'Bancontact', 'ideal' => 'iDEAL', + 'aio_checkout' => 'All-in-one checkout', ); return $LANG; diff --git a/resources/views/portal/ninja2020/gateways/razorpay/hosted/authorize.blade.php b/resources/views/portal/ninja2020/gateways/razorpay/hosted/authorize.blade.php new file mode 100644 index 000000000000..4f78e4bb4fd4 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/razorpay/hosted/authorize.blade.php @@ -0,0 +1,8 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.aio_checkout'), 'card_title' => +ctrans('texts.aio_checkout')]) + +@section('gateway_content') + @component('portal.ninja2020.components.general.card-element-single') + {{ __('texts.payment_method_cannot_be_preauthorized') }} + @endcomponent +@endsection \ No newline at end of file diff --git a/resources/views/portal/ninja2020/gateways/razorpay/hosted/pay.blade.php b/resources/views/portal/ninja2020/gateways/razorpay/hosted/pay.blade.php new file mode 100644 index 000000000000..43a76890f809 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/razorpay/hosted/pay.blade.php @@ -0,0 +1,36 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.aio_checkout'), 'card_title' => +ctrans('texts.aio_checkout')]) + +@section('gateway_head') + +@endsection + +@section('gateway_content') +
+ + + + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')]) + {{ ctrans('texts.aio_checkout') }} + @endcomponent + + @include('portal.ninja2020.gateways.includes.payment_details') + + @include('portal.ninja2020.gateways.includes.pay_now') +@endsection + +@section('gateway_footer') + + +@endsection diff --git a/webpack.mix.js b/webpack.mix.js index 01fcbf04df88..906679251a59 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -106,6 +106,10 @@ mix.js("resources/js/app.js", "public/js") "resources/js/clients/statements/view.js", "public/js/clients/statements/view.js", ) + .js( + "resources/js/clients/payments/razorpay-aio.js", + "public/js/clients/payments/razorpay-aio.js" + ) mix.copyDirectory('node_modules/card-js/card-js.min.css', 'public/css/card-js.min.css');