From e77c3492edaee719a3d68d12487b5d34dc8eec22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Wed, 28 Apr 2021 12:54:27 +0200 Subject: [PATCH 01/26] wip --- app/Models/SystemLog.php | 3 +- app/PaymentDrivers/Braintree/CreditCard.php | 43 ++++++++++++ app/PaymentDrivers/BraintreePaymentDriver.php | 63 +++++++++++++++++ .../braintree/credit_card/pay.blade.php | 68 +++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 app/PaymentDrivers/Braintree/CreditCard.php create mode 100644 app/PaymentDrivers/BraintreePaymentDriver.php create mode 100644 resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php diff --git a/app/Models/SystemLog.php b/app/Models/SystemLog.php index 816846ba255f..3ad12a351cb1 100644 --- a/app/Models/SystemLog.php +++ b/app/Models/SystemLog.php @@ -65,7 +65,8 @@ class SystemLog extends Model const TYPE_CHECKOUT = 304; const TYPE_AUTHORIZE = 305; const TYPE_CUSTOM = 306; - + const TYPE_BRAINTREE = 307; + const TYPE_QUOTA_EXCEEDED = 400; const TYPE_UPSTREAM_FAILURE = 401; diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php new file mode 100644 index 000000000000..8ffb51bc812f --- /dev/null +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -0,0 +1,43 @@ +braintree = $braintree; + } + + /** + * Credit card payment page. + * + * @param array $data + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function paymentView(array $data) + { + $data['gateway'] = $this->braintree; + $data['client_token'] = + + return render('gateways.braintree.credit_card.pay', $data); + } +} diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php new file mode 100644 index 000000000000..198fc72a2b0b --- /dev/null +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -0,0 +1,63 @@ + CreditCard::class, + GatewayType::PAYPAL, + ]; + + const SYSTEM_LOG_TYPE = SystemLog::TYPE_BRAINTREE; + + public function init() + { + + } + + public function setPaymentMethod($payment_method_id) + { + $class = self::$methods[$payment_method_id]; + + $this->payment_method = new $class($this); + + return $this; + } + + public function gatewayTypes(): array + { + return [ + GatewayType::CREDIT_CARD, + GatewayType::PAYPAL, + ]; + } + + public function processPaymentView(array $data) + { + return $this->payment_method->paymentView($data); + } +} diff --git a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php new file mode 100644 index 000000000000..984dfd1d7878 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php @@ -0,0 +1,68 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.credit_card'), 'card_title' => ctrans('texts.credit_card')]) + +@section('gateway_head') + + + +@endsection + +@section('gateway_content') +
+ @csrf + + + + + + + + +
+ + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')]) + {{ ctrans('texts.credit_card') }} + @endcomponent + + @include('portal.ninja2020.gateways.includes.payment_details') + + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')]) + @if(count($tokens) > 0) + @foreach($tokens as $token) + + @endforeach + @endisset + + + @endcomponent + + @component('portal.ninja2020.components.general.card-element-single') +
+ @endcomponent + + @include('portal.ninja2020.gateways.includes.pay_now') +@endsection + +@push('gateway_footer') + +@endpush From 9c97421a55324c4940e37e4ea075d9b40d92d30d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Wed, 28 Apr 2021 14:54:50 +0200 Subject: [PATCH 02/26] wip --- .../ClientPortal/PaymentController.php | 12 --- app/PaymentDrivers/Braintree/CreditCard.php | 100 +++++++++++++++++- app/PaymentDrivers/BraintreePaymentDriver.php | 19 +++- composer.json | 1 + composer.lock | 50 ++++++++- .../braintree/credit_card/pay.blade.php | 38 ++++++- 6 files changed, 202 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/ClientPortal/PaymentController.php b/app/Http/Controllers/ClientPortal/PaymentController.php index 6d17c3daff54..0cd74d6d63bf 100644 --- a/app/Http/Controllers/ClientPortal/PaymentController.php +++ b/app/Http/Controllers/ClientPortal/PaymentController.php @@ -299,24 +299,12 @@ class PaymentController extends Controller $payment_hash = PaymentHash::whereRaw('BINARY `hash`= ?', [$request->payment_hash])->first(); - try { return $gateway ->driver(auth()->user()->client) ->setPaymentMethod($request->input('payment_method_id')) ->setPaymentHash($payment_hash) ->checkRequirements() ->processPaymentResponse($request); - } catch (\Exception $e) { - SystemLogger::dispatch( - $e->getMessage(), - SystemLog::CATEGORY_GATEWAY_RESPONSE, - SystemLog::EVENT_GATEWAY_FAILURE, - SystemLog::TYPE_FAILURE, - auth('contact')->user()->client - ); - - throw new PaymentFailed($e->getMessage()); - } } /** diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php index 8ffb51bc812f..faebb85885e6 100644 --- a/app/PaymentDrivers/Braintree/CreditCard.php +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -13,6 +13,15 @@ namespace App\PaymentDrivers\Braintree; +use App\Exceptions\PaymentFailed; +use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest; +use App\Http\Requests\Request; +use App\Jobs\Mail\PaymentFailureMailer; +use App\Jobs\Util\SystemLogger; +use App\Models\GatewayType; +use App\Models\Payment; +use App\Models\PaymentType; +use App\Models\SystemLog; use App\PaymentDrivers\BraintreePaymentDriver; class CreditCard @@ -25,6 +34,8 @@ class CreditCard public function __construct(BraintreePaymentDriver $braintree) { $this->braintree = $braintree; + + $this->braintree->init(); } /** @@ -36,8 +47,95 @@ class CreditCard public function paymentView(array $data) { $data['gateway'] = $this->braintree; - $data['client_token'] = + $data['client_token'] = $this->braintree->gateway->clientToken()->generate(); return render('gateways.braintree.credit_card.pay', $data); } + + public function paymentResponse(PaymentResponseRequest $request) + { + $state = [ + 'server_response' => json_decode($request->gateway_response), + 'payment_hash' => $request->payment_hash, + ]; + + $state = array_merge($state, $request->all()); + $state['store_card'] = boolval($state['store_card']); + + $this->braintree->payment_hash->data = array_merge((array)$this->braintree->payment_hash->data, $state); + $this->braintree->payment_hash->save(); + + $result = $this->braintree->gateway->transaction()->sale([ + 'amount' => '2000.50', + 'paymentMethodNonce' => $state['token'], + 'deviceData' => $state['client-data'], + 'options' => [ + 'submitForSettlement' => true + ], + ]); + + if ($result->success) { + $this->braintree->logSuccessfulGatewayResponse(['response' => $request->server_response, 'data' => $this->braintree->payment_hash], SystemLog::TYPE_BRAINTREE); + + return $this->processSuccessfulPayment($result); + } + + return $this->processUnsuccessfulPayment($result); + } + + private function processSuccessfulPayment($response) + { + $state = $this->braintree->payment_hash->data; + + $data = [ + 'payment_type' => PaymentType::parseCardType(strtolower($state->server_response->details->cardType)), + 'amount' => 10, + 'transaction_reference' => $response->transaction->id, + 'gateway_type_id' => GatewayType::CREDIT_CARD, + ]; + + // Store card if checkbox selected. + + $payment = $this->braintree->createPayment($data, Payment::STATUS_COMPLETED); + + SystemLogger::dispatch( + ['response' => $response, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_BRAINTREE, + $this->braintree->client + ); + + return redirect()->route('client.payments.show', ['payment' => $this->braintree->encodePrimaryKey($payment->id)]); + } + + /** + * @throws PaymentFailed + */ + private function processUnsuccessfulPayment($response) + { + PaymentFailureMailer::dispatch($this->braintree->client, $response->transaction->additionalProcessorResponse, $this->braintree->client->company, 10); + + PaymentFailureMailer::dispatch( + $this->braintree->client, + $response, + $this->braintree->client->company, + 10, + ); + + $message = [ + 'server_response' => $response, + 'data' => $this->braintree->payment_hash->data, + ]; + + SystemLogger::dispatch( + $message, + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_BRAINTREE, + $this->braintree->client + ); + + throw new PaymentFailed($response->transaction->additionalProcessorResponse, $response->transaction->processorResponseCode); + } } diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index 198fc72a2b0b..cdcf693a2ab0 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -13,9 +13,11 @@ namespace App\PaymentDrivers; +use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest; use App\Models\GatewayType; use App\Models\SystemLog; use App\PaymentDrivers\Braintree\CreditCard; +use Illuminate\Http\Request; class BraintreePaymentDriver extends BaseDriver { @@ -25,6 +27,9 @@ class BraintreePaymentDriver extends BaseDriver public $can_authorise_credit_card = true; + /** + * @var \Braintree\Gateway; + */ public $gateway; public static $methods = [ @@ -34,9 +39,14 @@ class BraintreePaymentDriver extends BaseDriver const SYSTEM_LOG_TYPE = SystemLog::TYPE_BRAINTREE; - public function init() + public function init(): void { - + $this->gateway = new \Braintree\Gateway([ + 'environment' => $this->company_gateway->getConfigField('testMode') ? 'sandbox' : 'production', + 'merchantId' => $this->company_gateway->getConfigField('merchantId'), + 'publicKey' => $this->company_gateway->getConfigField('publicKey'), + 'privateKey' => $this->company_gateway->getConfigField('privateKey'), + ]); } public function setPaymentMethod($payment_method_id) @@ -60,4 +70,9 @@ class BraintreePaymentDriver extends BaseDriver { return $this->payment_method->paymentView($data); } + + public function processPaymentResponse($request) + { + return $this->payment_method->paymentResponse($request); + } } diff --git a/composer.json b/composer.json index 12254fd48628..41737065c251 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "authorizenet/authorizenet": "^2.0", "bacon/bacon-qr-code": "^2.0", "beganovich/snappdf": "^1.0", + "braintree/braintree_php": "^6.0", "checkout/checkout-sdk-php": "^1.0", "cleverit/ubl_invoice": "^1.3", "coconutcraig/laravel-postmark": "^2.10", diff --git a/composer.lock b/composer.lock index 66898315b8b0..a87e22a543d4 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": "f01381d3d00f0bd84acbda078ad1b99e", + "content-hash": "7ded6dd53f8327fdf7bbcfd4e77a5080", "packages": [ { "name": "authorizenet/authorizenet", @@ -245,6 +245,54 @@ }, "time": "2021-03-19T21:20:07+00:00" }, + { + "name": "braintree/braintree_php", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/braintree/braintree_php.git", + "reference": "e22dc11e5e92182999288d914d14640a3f50cd85" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/braintree/braintree_php/zipball/e22dc11e5e92182999288d914d14640a3f50cd85", + "reference": "e22dc11e5e92182999288d914d14640a3f50cd85", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-dom": "*", + "ext-hash": "*", + "ext-openssl": "*", + "ext-xmlwriter": "*", + "php": ">=7.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Braintree\\": "lib/Braintree" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Braintree", + "homepage": "https://www.braintreepayments.com" + } + ], + "description": "Braintree PHP Client Library", + "support": { + "issues": "https://github.com/braintree/braintree_php/issues", + "source": "https://github.com/braintree/braintree_php/tree/6.0.0" + }, + "time": "2021-04-06T21:27:03+00:00" + }, { "name": "brick/math", "version": "0.9.2", diff --git a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php index 984dfd1d7878..25c109c6972f 100644 --- a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php @@ -4,6 +4,8 @@ + + @endsection @section('gateway_content') @@ -17,6 +19,7 @@ + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')]) @@ -57,12 +60,43 @@ @include('portal.ninja2020.gateways.includes.pay_now') @endsection -@push('gateway_footer') +@section('gateway_footer') -@endpush +@endsection From 7d8c135ba7637b0886781119b29c9759ae5e4233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Wed, 28 Apr 2021 15:03:22 +0200 Subject: [PATCH 03/26] wip --- app/PaymentDrivers/Braintree/CreditCard.php | 21 +++++++++++++++++-- app/PaymentDrivers/BraintreePaymentDriver.php | 10 +++++++++ resources/lang/en/texts.php | 2 ++ .../braintree/credit_card/authorize.blade.php | 7 +++++++ .../checkout/credit_card/authorize.blade.php | 2 +- 5 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 resources/views/portal/ninja2020/gateways/braintree/credit_card/authorize.blade.php diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php index faebb85885e6..1ccaac3b78cb 100644 --- a/app/PaymentDrivers/Braintree/CreditCard.php +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -38,6 +38,18 @@ class CreditCard $this->braintree->init(); } + public function authorizeView(array $data) + { + $data['gateway'] = $this->braintree; + + return render('gateways.braintree.credit_card.authorize', $data); + } + + public function authorizeResponse($data) + { + + } + /** * Credit card payment page. * @@ -52,6 +64,13 @@ class CreditCard return render('gateways.braintree.credit_card.pay', $data); } + /** + * Process the credit card payments. + * + * @param PaymentResponseRequest $request + * @return \Illuminate\Http\RedirectResponse|void + * @throws PaymentFailed + */ public function paymentResponse(PaymentResponseRequest $request) { $state = [ @@ -94,8 +113,6 @@ class CreditCard 'gateway_type_id' => GatewayType::CREDIT_CARD, ]; - // Store card if checkbox selected. - $payment = $this->braintree->createPayment($data, Payment::STATUS_COMPLETED); SystemLogger::dispatch( diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index cdcf693a2ab0..bd0f4d884877 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -66,6 +66,16 @@ class BraintreePaymentDriver extends BaseDriver ]; } + public function authorizeView($data) + { + return $this->payment_method->authorizeView($data); + } + + public function authorizeResponse($data) + { + return $this->payment_method->authorizeResponse($data); + } + public function processPaymentView(array $data) { return $this->payment_method->paymentView($data); diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index 2619e20330fc..bc3acc44f4f5 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -4235,6 +4235,8 @@ $LANG = array( 'notification_quote_created_subject' => 'Quote :invoice was created for :client', 'notification_credit_created_subject' => 'Credit :invoice was created to :client', 'notification_credit_created_subject' => 'Credit :invoice was created for :client', + + 'payment_method_cannot_be_authorized_first' => 'This payment method can be can saved for future use, once you complete your first transaction. Don\'t forget to check "Store credit card details" during payment process.', ); return $LANG; diff --git a/resources/views/portal/ninja2020/gateways/braintree/credit_card/authorize.blade.php b/resources/views/portal/ninja2020/gateways/braintree/credit_card/authorize.blade.php new file mode 100644 index 000000000000..fac189f8847e --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/braintree/credit_card/authorize.blade.php @@ -0,0 +1,7 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'Credit card', 'card_title' => 'Credit card']) + +@section('gateway_content') + @component('portal.ninja2020.components.general.card-element-single', ['title' => 'Credit card', 'show_title' => false]) + {{ __('texts.payment_method_cannot_be_authorized_first') }} + @endcomponent +@endsection diff --git a/resources/views/portal/ninja2020/gateways/checkout/credit_card/authorize.blade.php b/resources/views/portal/ninja2020/gateways/checkout/credit_card/authorize.blade.php index 9100f9695ff6..9bf42c2b22a2 100644 --- a/resources/views/portal/ninja2020/gateways/checkout/credit_card/authorize.blade.php +++ b/resources/views/portal/ninja2020/gateways/checkout/credit_card/authorize.blade.php @@ -4,4 +4,4 @@ @component('portal.ninja2020.components.general.card-element-single', ['title' => 'Credit card', 'show_title' => false]) {{ __('texts.checkout_authorize_label') }} @endcomponent -@endsection \ No newline at end of file +@endsection From d4c51871efecc378458a59eccf40e41fc9259022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Thu, 29 Apr 2021 11:29:28 +0200 Subject: [PATCH 04/26] - Update Braintree credit card to use amount_with_fee - Add amount_with_fee to payment hash --- .../Controllers/ClientPortal/PaymentController.php | 2 +- app/PaymentDrivers/Braintree/CreditCard.php | 10 +++++----- .../gateways/braintree/credit_card/pay.blade.php | 6 ++++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/ClientPortal/PaymentController.php b/app/Http/Controllers/ClientPortal/PaymentController.php index 0cd74d6d63bf..78456482827c 100644 --- a/app/Http/Controllers/ClientPortal/PaymentController.php +++ b/app/Http/Controllers/ClientPortal/PaymentController.php @@ -239,7 +239,7 @@ class PaymentController extends Controller ->get(); } - $hash_data = ['invoices' => $payable_invoices->toArray(), 'credits' => $credit_totals]; + $hash_data = ['invoices' => $payable_invoices->toArray(), 'credits' => $credit_totals, 'amount_with_fee' => max(0, (($invoice_totals + $fee_totals) - $credit_totals))]; if ($request->query('hash')) { $hash_data['billing_context'] = Cache::get($request->query('hash')); diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php index 1ccaac3b78cb..0e79aa692224 100644 --- a/app/PaymentDrivers/Braintree/CreditCard.php +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -45,9 +45,9 @@ class CreditCard return render('gateways.braintree.credit_card.authorize', $data); } - public function authorizeResponse($data) + public function authorizeResponse($data): \Illuminate\Http\RedirectResponse { - + return back(); } /** @@ -85,7 +85,7 @@ class CreditCard $this->braintree->payment_hash->save(); $result = $this->braintree->gateway->transaction()->sale([ - 'amount' => '2000.50', + 'amount' => $this->braintree->payment_hash->data->amount_with_fee, 'paymentMethodNonce' => $state['token'], 'deviceData' => $state['client-data'], 'options' => [ @@ -108,7 +108,7 @@ class CreditCard $data = [ 'payment_type' => PaymentType::parseCardType(strtolower($state->server_response->details->cardType)), - 'amount' => 10, + 'amount' => $this->braintree->payment_hash->data->amount_with_fee, 'transaction_reference' => $response->transaction->id, 'gateway_type_id' => GatewayType::CREDIT_CARD, ]; @@ -137,7 +137,7 @@ class CreditCard $this->braintree->client, $response, $this->braintree->client->company, - 10, + $this->braintree->payment_hash->data->amount_with_fee, ); $message = [ diff --git a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php index 25c109c6972f..5dc59263a151 100644 --- a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php @@ -6,6 +6,12 @@ + + @endsection @section('gateway_content') From 6cab52fc9f2082427d0027c7cb307c655e7c08a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Thu, 29 Apr 2021 11:59:18 +0200 Subject: [PATCH 05/26] Show "Save card" option for credit card --- app/PaymentDrivers/Braintree/CreditCard.php | 30 +++++++++++++++++++ .../braintree/credit_card/pay.blade.php | 11 +++++++ .../gateways/includes/save_card.blade.php | 4 +-- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php index 0e79aa692224..6985770c76a9 100644 --- a/app/PaymentDrivers/Braintree/CreditCard.php +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -96,6 +96,10 @@ class CreditCard if ($result->success) { $this->braintree->logSuccessfulGatewayResponse(['response' => $request->server_response, 'data' => $this->braintree->payment_hash], SystemLog::TYPE_BRAINTREE); + if ($request->store_card) { + $this->storePaymentMethod(); + } + return $this->processSuccessfulPayment($result); } @@ -155,4 +159,30 @@ class CreditCard throw new PaymentFailed($response->transaction->additionalProcessorResponse, $response->transaction->processorResponseCode); } + + private function storePaymentMethod() + { + return; + + $method = $this->braintree->payment_hash->data->server_response->details; + + try { + $payment_meta = new \stdClass; + $payment_meta->exp_month = (string) $method->expirationMonth; + $payment_meta->exp_year = (string) $method->expirationYear; + $payment_meta->brand = (string) $method->cardType; + $payment_meta->last4 = (string) $method->lastFour; + $payment_meta->type = GatewayType::CREDIT_CARD; + + $data = [ + 'payment_meta' => $payment_meta, + 'token' => $method->id, + 'payment_method_id' => $this->braintree->payment_hash->data->payment_method_id, + ]; + + $this->braintree->storeGatewayToken($data, ['gateway_customer_reference' => $customer->id]); + } catch (\Exception $e) { + return $this->braintree->processInternallyFailedPayment($this->braintree, $e); + } + } } diff --git a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php index 5dc59263a151..879cabdf97f7 100644 --- a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php @@ -59,6 +59,8 @@ @endcomponent + @include('portal.ninja2020.gateways.includes.save_card') + @component('portal.ninja2020.components.general.card-element-single')
@endcomponent @@ -100,6 +102,15 @@ document.querySelector('input[name=token]').value = payload.nonce; document.querySelector('input[name=gateway_response]').value = JSON.stringify(payload); + let tokenBillingCheckbox = document.querySelector( + 'input[name="token-billing-checkbox"]:checked' + ); + + if (tokenBillingCheckbox) { + document.querySelector('input[name="store_card"]').value = + tokenBillingCheckbox.value; + } + document.getElementById('server-response').submit(); }); }); diff --git a/resources/views/portal/ninja2020/gateways/includes/save_card.blade.php b/resources/views/portal/ninja2020/gateways/includes/save_card.blade.php index 34a5b3671075..e6f7d55ea043 100644 --- a/resources/views/portal/ninja2020/gateways/includes/save_card.blade.php +++ b/resources/views/portal/ninja2020/gateways/includes/save_card.blade.php @@ -16,12 +16,12 @@ value="true"/> {{ ctrans('texts.yes') }} - + @else From 77733ffd0afe89c86b04c9250f1b5f8b9d4bacc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Thu, 29 Apr 2021 15:05:45 +0200 Subject: [PATCH 06/26] Saving & tokenizing the credit card --- app/PaymentDrivers/Braintree/CreditCard.php | 32 +++++++++++-------- app/PaymentDrivers/BraintreePaymentDriver.php | 23 +++++++++++++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php index 6985770c76a9..23e28cd5a135 100644 --- a/app/PaymentDrivers/Braintree/CreditCard.php +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -84,9 +84,19 @@ class CreditCard $this->braintree->payment_hash->data = array_merge((array)$this->braintree->payment_hash->data, $state); $this->braintree->payment_hash->save(); + $customer = $this->braintree->findOrCreateCustomer(); + + $payment_method = $this->braintree->gateway->paymentMethod()->create([ + 'customerId' => $customer->id, + 'paymentMethodNonce' => $state['token'], + 'options' => [ + 'verifyCard' => true, + ], + ]); + $result = $this->braintree->gateway->transaction()->sale([ 'amount' => $this->braintree->payment_hash->data->amount_with_fee, - 'paymentMethodNonce' => $state['token'], + 'paymentMethodToken' => $payment_method->paymentMethod->token, 'deviceData' => $state['client-data'], 'options' => [ 'submitForSettlement' => true @@ -97,7 +107,7 @@ class CreditCard $this->braintree->logSuccessfulGatewayResponse(['response' => $request->server_response, 'data' => $this->braintree->payment_hash], SystemLog::TYPE_BRAINTREE); if ($request->store_card) { - $this->storePaymentMethod(); + $this->storePaymentMethod($payment_method, $customer->id); } return $this->processSuccessfulPayment($result); @@ -160,27 +170,23 @@ class CreditCard throw new PaymentFailed($response->transaction->additionalProcessorResponse, $response->transaction->processorResponseCode); } - private function storePaymentMethod() + private function storePaymentMethod($method, $customer_reference) { - return; - - $method = $this->braintree->payment_hash->data->server_response->details; - try { $payment_meta = new \stdClass; - $payment_meta->exp_month = (string) $method->expirationMonth; - $payment_meta->exp_year = (string) $method->expirationYear; - $payment_meta->brand = (string) $method->cardType; - $payment_meta->last4 = (string) $method->lastFour; + $payment_meta->exp_month = (string)$method->paymentMethod->expirationMonth; + $payment_meta->exp_year = (string)$method->paymentMethod->expirationYear; + $payment_meta->brand = (string)$method->paymentMethod->cardType; + $payment_meta->last4 = (string)$method->paymentMethod->last4; $payment_meta->type = GatewayType::CREDIT_CARD; $data = [ 'payment_meta' => $payment_meta, - 'token' => $method->id, + 'token' => $method->paymentMethod->token, 'payment_method_id' => $this->braintree->payment_hash->data->payment_method_id, ]; - $this->braintree->storeGatewayToken($data, ['gateway_customer_reference' => $customer->id]); + $this->braintree->storeGatewayToken($data, ['gateway_customer_reference' => $customer_reference]); } catch (\Exception $e) { return $this->braintree->processInternallyFailedPayment($this->braintree, $e); } diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index bd0f4d884877..a9c09523a4b9 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -14,6 +14,7 @@ namespace App\PaymentDrivers; use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest; +use App\Models\ClientGatewayToken; use App\Models\GatewayType; use App\Models\SystemLog; use App\PaymentDrivers\Braintree\CreditCard; @@ -85,4 +86,26 @@ class BraintreePaymentDriver extends BaseDriver { return $this->payment_method->paymentResponse($request); } + + public function findOrCreateCustomer() + { + $existing = ClientGatewayToken::query() + ->where('company_gateway_id', $this->company_gateway->id) + ->where('client_id', $this->client->id) + ->first(); + + if ($existing) { + return $this->gateway->customer()->find($existing->gateway_customer_reference); + } + + $result = $this->gateway->customer()->create([ + 'firstName' => $this->client->present()->name, + 'email' => $this->client->present()->email, + 'phone' => $this->client->present()->phone, + ]); + + if ($result->success) { + return $result->customer; + } + } } From 2ab65b12fa5d90683e9b1d50d224fe98f51e78f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Thu, 29 Apr 2021 16:43:59 +0200 Subject: [PATCH 07/26] Ability to pay with token --- app/PaymentDrivers/Braintree/CreditCard.php | 33 +++-- .../braintree/credit_card/pay.blade.php | 125 +++++++++++++----- 2 files changed, 113 insertions(+), 45 deletions(-) diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php index 23e28cd5a135..88359029dc5b 100644 --- a/app/PaymentDrivers/Braintree/CreditCard.php +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -86,17 +86,9 @@ class CreditCard $customer = $this->braintree->findOrCreateCustomer(); - $payment_method = $this->braintree->gateway->paymentMethod()->create([ - 'customerId' => $customer->id, - 'paymentMethodNonce' => $state['token'], - 'options' => [ - 'verifyCard' => true, - ], - ]); - $result = $this->braintree->gateway->transaction()->sale([ 'amount' => $this->braintree->payment_hash->data->amount_with_fee, - 'paymentMethodToken' => $payment_method->paymentMethod->token, + 'paymentMethodToken' => $this->getPaymentToken($request->all(), $customer->id), 'deviceData' => $state['client-data'], 'options' => [ 'submitForSettlement' => true @@ -106,7 +98,7 @@ class CreditCard if ($result->success) { $this->braintree->logSuccessfulGatewayResponse(['response' => $request->server_response, 'data' => $this->braintree->payment_hash], SystemLog::TYPE_BRAINTREE); - if ($request->store_card) { + if ($request->store_card && is_null($request->token)) { $this->storePaymentMethod($payment_method, $customer->id); } @@ -116,12 +108,31 @@ class CreditCard return $this->processUnsuccessfulPayment($result); } + private function getPaymentToken(array $data, $customerId): ?string + { + if (array_key_exists('token', $data) && !is_null($data['token'])) { + return $data['token']; + } + + $gateway_response = json_decode($data['gateway_response']); + + $payment_method = $this->braintree->gateway->paymentMethod()->create([ + 'customerId' => $customerId, + 'paymentMethodNonce' => $gateway_response->nonce, + 'options' => [ + 'verifyCard' => true, + ], + ]); + + return $payment_method->paymentMethod->token; + } + private function processSuccessfulPayment($response) { $state = $this->braintree->payment_hash->data; $data = [ - 'payment_type' => PaymentType::parseCardType(strtolower($state->server_response->details->cardType)), + 'payment_type' => PaymentType::parseCardType(strtolower($response->transaction->creditCard['cardType'])), 'amount' => $this->braintree->payment_hash->data->amount_with_fee, 'transaction_reference' => $response->transaction->id, 'gateway_type_id' => GatewayType::CREDIT_CARD, diff --git a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php index 879cabdf97f7..2d82adcf33b9 100644 --- a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php @@ -66,54 +66,111 @@ @endcomponent @include('portal.ninja2020.gateways.includes.pay_now') + @include('portal.ninja2020.gateways.includes.pay_now', ['id' => 'pay-now-with-token', 'class' => 'hidden']) @endsection @section('gateway_footer') @endsection From bc23de2ce276b895ceb448af4c5885210320ab55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Thu, 29 Apr 2021 16:51:28 +0200 Subject: [PATCH 08/26] Extract credit cards to separate Javascript --- public/css/app.css | 2 +- .../clients/payments/braintree-credit-card.js | 2 + .../braintree-credit-card.js.LICENSE.txt | 9 ++ public/mix-manifest.json | 3 +- .../clients/payments/braintree-credit-card.js | 112 ++++++++++++++++++ .../braintree/credit_card/pay.blade.php | 104 +--------------- webpack.mix.js | 4 + 7 files changed, 131 insertions(+), 105 deletions(-) create mode 100644 public/js/clients/payments/braintree-credit-card.js create mode 100644 public/js/clients/payments/braintree-credit-card.js.LICENSE.txt create mode 100644 resources/js/clients/payments/braintree-credit-card.js diff --git a/public/css/app.css b/public/css/app.css index 9c5b8ef2d717..3d714b48f25a 100755 --- a/public/css/app.css +++ b/public/css/app.css @@ -1 +1 @@ -/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}details{display:block}summary{display:list-item}[hidden],template{display:none}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}button{background-color:transparent;background-image:none}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}fieldset,ol,ul{margin:0;padding:0}ol,ul{list-style:none}html{font-family:Open Sans,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}*,:after,:before{box-sizing:border-box;border:0 solid #d2d6dc}hr{border-top-width:1px}img{border-style:solid}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#a0aec0}input:-ms-input-placeholder,textarea:-ms-input-placeholder{color:#a0aec0}input::placeholder,textarea::placeholder{color:#a0aec0}[role=button],button{cursor:pointer}table{border-collapse:collapse}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}button,input,optgroup,select,textarea{padding:0;line-height:inherit;color:inherit}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}.form-select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M7 7l3-3 3 3m0 6l-3 3-3-3' stroke='%239fa6b2' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;background-repeat:no-repeat;background-color:#fff;border-color:#d2d6dc;border-width:1px;border-radius:.375rem;padding:.5rem 2.5rem .5rem .75rem;font-size:1rem;line-height:1.5;background-position:right .5rem center;background-size:1.5em 1.5em}.form-select::-ms-expand{color:#9fa6b2;border:none}@media not print{.form-select::-ms-expand{display:none}}@media print and (-ms-high-contrast:active),print and (-ms-high-contrast:none){.form-select{padding-right:.75rem}}.form-select:focus{outline:none;box-shadow:0 0 0 3px rgba(164,202,254,.45);border-color:#a4cafe}.form-checkbox:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293z'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media not print{.form-checkbox::-ms-check{border-width:1px;color:transparent;background:inherit;border-color:inherit;border-radius:inherit}}.form-checkbox{-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#3f83f8;background-color:#fff;border-color:#d2d6dc;border-width:1px;border-radius:.25rem}.form-checkbox:focus{outline:none;box-shadow:0 0 0 3px rgba(164,202,254,.45);border-color:#a4cafe}.form-checkbox:checked:focus,.form-radio:checked{border-color:transparent}.form-radio:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E");background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media not print{.form-radio::-ms-check{border-width:1px;color:transparent;background:inherit;border-color:inherit;border-radius:inherit}}.form-radio{-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;flex-shrink:0;border-radius:100%;height:1rem;width:1rem;color:#3f83f8;background-color:#fff;border-color:#d2d6dc;border-width:1px}.form-radio:focus{outline:none;box-shadow:0 0 0 3px rgba(164,202,254,.45);border-color:#a4cafe}.form-radio:checked:focus{border-color:transparent}.button{border-radius:.25rem;padding:.75rem 1rem;font-size:.875rem;line-height:1rem;transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}button:disabled{opacity:.5;cursor:not-allowed}.button-primary{--text-opacity:1;color:#fff;color:rgba(255,255,255,var(--text-opacity))}.button-primary:hover{font-weight:600}.button-block{display:block;width:100%}.button-danger{--bg-opacity:1;background-color:#f05252;background-color:rgba(240,82,82,var(--bg-opacity));--text-opacity:1;color:#fff;color:rgba(255,255,255,var(--text-opacity))}.button-danger:hover{--bg-opacity:1;background-color:#e02424;background-color:rgba(224,36,36,var(--bg-opacity))}.button-secondary{--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity))}.button-secondary:hover{--bg-opacity:1;background-color:#e5e7eb;background-color:rgba(229,231,235,var(--bg-opacity))}.button-link:hover{font-weight:600;text-decoration:underline}.button-link:focus{outline:2px solid transparent;outline-offset:2px;text-decoration:underline}.validation{border-left-width:2px;margin-top:.5rem;margin-bottom:.25rem;--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity));padding:.25rem .75rem}.validation-fail{border-color:#f05252;border-color:rgba(240,82,82,var(--border-opacity))}.validation-fail,.validation-pass{--border-opacity:1;--text-opacity:1;color:#374151;color:rgba(55,65,81,var(--text-opacity));font-size:.875rem}.validation-pass{border-color:#0e9f6e;border-color:rgba(14,159,110,var(--border-opacity))}.input{align-items:center;border-width:1px;--border-opacity:1;border-color:#d2d6dc;border-color:rgba(210,214,220,var(--border-opacity));border-radius:.25rem;margin-top:.5rem;padding:.5rem 1rem;font-size:.875rem}.input:focus{outline:2px solid transparent;outline-offset:2px;--bg-opacity:1;background-color:#f9fafb;background-color:rgba(249,250,251,var(--bg-opacity))}.input-label{font-size:.875rem;--text-opacity:1;color:#4b5563;color:rgba(75,85,99,var(--text-opacity))}.input-slim{padding-top:.5rem;padding-bottom:.5rem}.alert{padding:.75rem 1rem;font-size:.875rem;border-left-width:2px;margin-top:.5rem;margin-bottom:.25rem;--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity));--border-opacity:1;border-color:#9fa6b2;border-color:rgba(159,166,178,var(--border-opacity))}.alert-success{--border-opacity:1;border-color:#0e9f6e;border-color:rgba(14,159,110,var(--border-opacity))}.alert-failure{--border-opacity:1;border-color:#f05252;border-color:rgba(240,82,82,var(--border-opacity))}.badge{display:inline-flex;align-items:center;padding:.125rem .625rem;border-radius:9999px;font-size:.75rem;font-weight:500;line-height:1rem}.badge-light{background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity));color:#252f3f;color:rgba(37,47,63,var(--text-opacity))}.badge-light,.badge-primary{--bg-opacity:1;--text-opacity:1}.badge-primary{background-color:#c3ddfd;background-color:rgba(195,221,253,var(--bg-opacity));color:#3f83f8;color:rgba(63,131,248,var(--text-opacity))}.badge-danger{background-color:#fde8e8;background-color:rgba(253,232,232,var(--bg-opacity));color:#f05252;color:rgba(240,82,82,var(--text-opacity))}.badge-danger,.badge-success{--bg-opacity:1;--text-opacity:1}.badge-success{background-color:#def7ec;background-color:rgba(222,247,236,var(--bg-opacity));color:#0e9f6e;color:rgba(14,159,110,var(--text-opacity))}.badge-secondary{--bg-opacity:1;background-color:#252f3f;background-color:rgba(37,47,63,var(--bg-opacity));--text-opacity:1;color:#e5e7eb;color:rgba(229,231,235,var(--text-opacity))}.badge-warning{background-color:#feecdc;background-color:rgba(254,236,220,var(--bg-opacity));color:#ff5a1f;color:rgba(255,90,31,var(--text-opacity))}.badge-info,.badge-warning{--bg-opacity:1;--text-opacity:1}.badge-info{background-color:#e1effe;background-color:rgba(225,239,254,var(--bg-opacity));color:#3f83f8;color:rgba(63,131,248,var(--text-opacity))}@media (min-width:640px){.dataTables_length{margin-top:1.25rem!important;margin-bottom:1.25rem!important}}@media (min-width:1024px){.dataTables_length{margin-top:1rem!important;margin-bottom:1rem!important}}.dataTables_length select{--bg-opacity:1!important;background-color:#fff!important;background-color:rgba(255,255,255,var(--bg-opacity))!important;align-items:center!important;border-width:1px!important;--border-opacity:1!important;border-color:#d2d6dc!important;border-color:rgba(210,214,220,var(--border-opacity))!important;border-radius:.25rem!important;margin-top:.5rem!important;font-size:.875rem!important;margin-left:.5rem!important;margin-right:.5rem!important;padding:.5rem!important}.dataTables_filter{margin-bottom:1rem}.dataTables_filter input{-webkit-appearance:none!important;-moz-appearance:none!important;appearance:none!important;background-color:#fff!important;border-radius:.375rem!important;font-size:1rem!important;line-height:1.5!important;align-items:center!important;border-width:1px!important;--border-opacity:1!important;border-color:#d2d6dc!important;border-color:rgba(210,214,220,var(--border-opacity))!important;border-radius:.25rem!important;margin-top:.5rem!important;padding:.5rem 1rem!important;font-size:.875rem!important}@media (min-width:1024px){.dataTables_filter{margin-top:-3rem!important}}.dataTables_paginate{padding-bottom:1.5rem!important;padding-top:.5rem!important}.dataTables_paginate .paginate_button{transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform!important;transition-duration:.15s!important;transition-timing-function:cubic-bezier(.4,0,.2,1)!important;--bg-opacity:1!important;background-color:#fff!important;background-color:rgba(255,255,255,var(--bg-opacity))!important;border-width:1px!important;--border-opacity:1!important;border-color:#d2d6dc!important;border-color:rgba(210,214,220,var(--border-opacity))!important;font-size:.875rem!important;line-height:1rem!important;font-weight:500!important;border-radius:.25rem!important;--text-opacity:1!important;color:#374151!important;color:rgba(55,65,81,var(--text-opacity))!important;margin-right:.25rem!important;padding:.5rem 1rem!important;cursor:pointer!important}.dataTables_paginate .current{--bg-opacity:1!important;background-color:#1c64f2!important;background-color:rgba(28,100,242,var(--bg-opacity))!important;--text-opacity:1!important;color:#fff!important;color:rgba(255,255,255,var(--text-opacity))!important}.dataTables_info{font-size:.875rem!important}.dataTables_empty{padding-top:1rem!important;padding-bottom:1rem!important}.pagination{display:flex!important;align-items:center!important}.pagination .page-link{margin-top:-1px!important;border-top-width:2px!important;border-color:transparent!important;padding-top:1rem!important;padding-left:1rem!important;padding-right:1rem!important;display:inline-flex!important;align-items:center!important;font-size:.875rem!important;line-height:1.25rem!important;font-weight:500!important;--text-opacity:1!important;color:#6b7280!important;color:rgba(107,114,128,var(--text-opacity))!important;transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform!important;transition-timing-function:cubic-bezier(.4,0,.2,1)!important;transition-duration:.15s!important;cursor:pointer!important}.pagination .page-link:hover{--text-opacity:1!important;color:#374151!important;color:rgba(55,65,81,var(--text-opacity))!important;--border-opacity:1!important;border-color:#d2d6dc!important;border-color:rgba(210,214,220,var(--border-opacity))!important}.pagination .page-link:focus{outline:2px solid transparent;outline-offset:2px;--text-opacity:1;color:#374151;color:rgba(55,65,81,var(--text-opacity));--border-opacity:1;border-color:#9fa6b2;border-color:rgba(159,166,178,var(--border-opacity))}.pagination .active>span{--text-opacity:1!important;color:#1c64f2!important;color:rgba(28,100,242,var(--text-opacity))!important;--border-opacity:1!important;border-color:#1c64f2!important;border-color:rgba(28,100,242,var(--border-opacity))!important}.space-x-1>:not(template)~:not(template){--space-x-reverse:0;margin-right:calc(0.25rem*var(--space-x-reverse));margin-left:calc(0.25rem*(1 - var(--space-x-reverse)))}.space-x-2>:not(template)~:not(template){--space-x-reverse:0;margin-right:calc(0.5rem*var(--space-x-reverse));margin-left:calc(0.5rem*(1 - var(--space-x-reverse)))}.bg-white{--bg-opacity:1;background-color:#fff;background-color:rgba(255,255,255,var(--bg-opacity))}.bg-gray-50{--bg-opacity:1;background-color:#f9fafb;background-color:rgba(249,250,251,var(--bg-opacity))}.bg-gray-100{--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity))}.bg-gray-500{--bg-opacity:1;background-color:#6b7280;background-color:rgba(107,114,128,var(--bg-opacity))}.bg-gray-600{--bg-opacity:1;background-color:#4b5563;background-color:rgba(75,85,99,var(--bg-opacity))}.bg-red-100{--bg-opacity:1;background-color:#fde8e8;background-color:rgba(253,232,232,var(--bg-opacity))}.bg-blue-50{--bg-opacity:1;background-color:#ebf5ff;background-color:rgba(235,245,255,var(--bg-opacity))}.bg-blue-600{--bg-opacity:1;background-color:#1c64f2;background-color:rgba(28,100,242,var(--bg-opacity))}.focus\:bg-gray-100:focus,.hover\:bg-gray-100:hover{--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity))}.focus\:bg-gray-600:focus{--bg-opacity:1;background-color:#4b5563;background-color:rgba(75,85,99,var(--bg-opacity))}.active\:bg-gray-50:active{--bg-opacity:1;background-color:#f9fafb;background-color:rgba(249,250,251,var(--bg-opacity))}.border-collapse{border-collapse:collapse}.border-black{--border-opacity:1;border-color:#000;border-color:rgba(0,0,0,var(--border-opacity))}.border-gray-100{--border-opacity:1;border-color:#f4f5f7;border-color:rgba(244,245,247,var(--border-opacity))}.border-gray-200{--border-opacity:1;border-color:#e5e7eb;border-color:rgba(229,231,235,var(--border-opacity))}.border-gray-300{--border-opacity:1;border-color:#d2d6dc;border-color:rgba(210,214,220,var(--border-opacity))}.border-red-300{--border-opacity:1;border-color:#f8b4b4;border-color:rgba(248,180,180,var(--border-opacity))}.border-red-400{--border-opacity:1;border-color:#f98080;border-color:rgba(249,128,128,var(--border-opacity))}.border-green-500{--border-opacity:1;border-color:#0e9f6e;border-color:rgba(14,159,110,var(--border-opacity))}.border-blue-500{--border-opacity:1;border-color:#3f83f8;border-color:rgba(63,131,248,var(--border-opacity))}.group:hover .group-hover\:border-transparent{border-color:transparent}.hover\:border-gray-800:hover{--border-opacity:1;border-color:#252f3f;border-color:rgba(37,47,63,var(--border-opacity))}.hover\:border-blue-600:hover{--border-opacity:1;border-color:#1c64f2;border-color:rgba(28,100,242,var(--border-opacity))}.focus\:border-blue-300:focus{--border-opacity:1;border-color:#a4cafe;border-color:rgba(164,202,254,var(--border-opacity))}.rounded-sm{border-radius:.125rem}.rounded{border-radius:.25rem}.rounded-md{border-radius:.375rem}.rounded-lg{border-radius:.5rem}.rounded-full{border-radius:9999px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-0{border-width:0}.border-4{border-width:4px}.border{border-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-r{border-right-width:1px}.border-b{border-bottom-width:1px}.cursor-pointer{cursor:pointer}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-bold{font-weight:700}.focus\:font-semibold:focus,.hover\:font-semibold:hover{font-weight:600}.h-0{height:0}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-12{height:3rem}.h-16{height:4rem}.h-32{height:8rem}.h-auto{height:auto}.h-screen{height:100vh}.text-xs{font-size:.75rem}.text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}.text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.text-3xl{font-size:1.875rem}.leading-4{line-height:1rem}.leading-5{line-height:1.25rem}.leading-6{line-height:1.5rem}.leading-9{line-height:2.25rem}.m-0{margin:0}.m-auto{margin:auto}.mx-2{margin-left:.5rem;margin-right:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.my-10{margin-top:2.5rem;margin-bottom:2.5rem}.mx-auto{margin-left:auto;margin-right:auto}.-my-2{margin-top:-.5rem;margin-bottom:-.5rem}.mt-0{margin-top:0}.mb-0{margin-bottom:0}.ml-0{margin-left:0}.mt-1{margin-top:.25rem}.mr-1{margin-right:.25rem}.ml-1{margin-left:.25rem}.mt-2{margin-top:.5rem}.mr-2{margin-right:.5rem}.mb-2{margin-bottom:.5rem}.ml-2{margin-left:.5rem}.mt-3{margin-top:.75rem}.mr-3{margin-right:.75rem}.mb-3{margin-bottom:.75rem}.ml-3{margin-left:.75rem}.mt-4{margin-top:1rem}.mr-4{margin-right:1rem}.mb-4{margin-bottom:1rem}.ml-4{margin-left:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mb-6{margin-bottom:1.5rem}.mt-8{margin-top:2rem}.mt-10{margin-top:2.5rem}.mb-10{margin-bottom:2.5rem}.mt-16{margin-top:4rem}.-mr-1{margin-right:-.25rem}.-ml-1{margin-left:-.25rem}.-mt-4{margin-top:-1rem}.-ml-4{margin-left:-1rem}.-mr-14{margin-right:-3.5rem}.max-w-xs{max-width:20rem}.max-w-xl{max-width:36rem}.max-w-2xl{max-width:42rem}.min-h-screen{min-height:100vh}.min-w-full{min-width:100%}.object-cover{-o-object-fit:cover;object-fit:cover}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.opacity-100{opacity:1}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-8{padding:2rem}.p-10{padding:2.5rem}.py-0{padding-top:0;padding-bottom:0}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-4{padding-left:1rem;padding-right:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.pl-0{padding-left:0}.pt-4{padding-top:1rem}.pr-4{padding-right:1rem}.pb-4{padding-bottom:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pb-20{padding-bottom:5rem}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{right:0;left:0}.inset-0,.inset-y-0{top:0;bottom:0}.inset-x-0{right:0;left:0}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.shadow-xs{box-shadow:0 0 0 1px rgba(0,0,0,.05)}.shadow-sm{box-shadow:0 1px 2px 0 rgba(0,0,0,.05)}.shadow{box-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px 0 rgba(0,0,0,.06)}.shadow-lg{box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05)}.shadow-xl{box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 10px 10px -5px rgba(0,0,0,.04)}.hover\:shadow-lg:hover{box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05)}.focus\:shadow-outline:focus{box-shadow:0 0 0 3px rgba(118,169,250,.45)}.focus\:shadow-outline-blue:focus{box-shadow:0 0 0 3px rgba(164,202,254,.45)}.fill-current{fill:currentColor}.table-auto{table-layout:auto}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-white{--text-opacity:1;color:#fff;color:rgba(255,255,255,var(--text-opacity))}.text-black{--text-opacity:1;color:#000;color:rgba(0,0,0,var(--text-opacity))}.text-gray-300{--text-opacity:1;color:#d2d6dc;color:rgba(210,214,220,var(--text-opacity))}.text-gray-400{--text-opacity:1;color:#9fa6b2;color:rgba(159,166,178,var(--text-opacity))}.text-gray-500{--text-opacity:1;color:#6b7280;color:rgba(107,114,128,var(--text-opacity))}.text-gray-600{--text-opacity:1;color:#4b5563;color:rgba(75,85,99,var(--text-opacity))}.text-gray-700{--text-opacity:1;color:#374151;color:rgba(55,65,81,var(--text-opacity))}.text-gray-800{--text-opacity:1;color:#252f3f;color:rgba(37,47,63,var(--text-opacity))}.text-gray-900{--text-opacity:1;color:#161e2e;color:rgba(22,30,46,var(--text-opacity))}.text-red-400{--text-opacity:1;color:#f98080;color:rgba(249,128,128,var(--text-opacity))}.text-red-600{--text-opacity:1;color:#e02424;color:rgba(224,36,36,var(--text-opacity))}.text-green-600{--text-opacity:1;color:#057a55;color:rgba(5,122,85,var(--text-opacity))}.text-blue-600{--text-opacity:1;color:#1c64f2;color:rgba(28,100,242,var(--text-opacity))}.hover\:text-gray-300:hover{--text-opacity:1;color:#d2d6dc;color:rgba(210,214,220,var(--text-opacity))}.hover\:text-gray-500:hover{--text-opacity:1;color:#6b7280;color:rgba(107,114,128,var(--text-opacity))}.hover\:text-gray-600:hover{--text-opacity:1;color:#4b5563;color:rgba(75,85,99,var(--text-opacity))}.hover\:text-gray-700:hover{--text-opacity:1;color:#374151;color:rgba(55,65,81,var(--text-opacity))}.hover\:text-gray-800:hover{--text-opacity:1;color:#252f3f;color:rgba(37,47,63,var(--text-opacity))}.hover\:text-gray-900:hover{--text-opacity:1;color:#161e2e;color:rgba(22,30,46,var(--text-opacity))}.hover\:text-blue-600:hover{--text-opacity:1;color:#1c64f2;color:rgba(28,100,242,var(--text-opacity))}.hover\:text-indigo-900:hover{--text-opacity:1;color:#362f78;color:rgba(54,47,120,var(--text-opacity))}.focus\:text-gray-500:focus{--text-opacity:1;color:#6b7280;color:rgba(107,114,128,var(--text-opacity))}.focus\:text-gray-600:focus{--text-opacity:1;color:#4b5563;color:rgba(75,85,99,var(--text-opacity))}.focus\:text-gray-900:focus{--text-opacity:1;color:#161e2e;color:rgba(22,30,46,var(--text-opacity))}.active\:text-gray-800:active{--text-opacity:1;color:#252f3f;color:rgba(37,47,63,var(--text-opacity))}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.underline{text-decoration:underline}.line-through{text-decoration:line-through}.focus\:underline:focus{text-decoration:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.align-middle{vertical-align:middle}.align-bottom{vertical-align:bottom}.whitespace-no-wrap{white-space:nowrap}.break-words{word-wrap:break-word;overflow-wrap:break-word}.break-all{word-break:break-all}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.w-0{width:0}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-48{width:12rem}.w-56{width:14rem}.w-64{width:16rem}.w-auto{width:auto}.w-1\/2{width:50%}.w-full{width:100%}.z-0{z-index:0}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.gap-4{grid-gap:1rem;gap:1rem}.gap-5{grid-gap:1.25rem;gap:1.25rem}.gap-6{grid-gap:1.5rem;gap:1.5rem}.gap-8{grid-gap:2rem;gap:2rem}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.col-span-4{grid-column:span 4/span 4}.col-span-6{grid-column:span 6/span 6}.col-span-7{grid-column:span 7/span 7}.col-span-12{grid-column:span 12/span 12}.col-start-3{grid-column-start:3}.transform{--transform-translate-x:0;--transform-translate-y:0;--transform-rotate:0;--transform-skew-x:0;--transform-skew-y:0;--transform-scale-x:1;--transform-scale-y:1;transform:translateX(var(--transform-translate-x)) translateY(var(--transform-translate-y)) rotate(var(--transform-rotate)) skewX(var(--transform-skew-x)) skewY(var(--transform-skew-y)) scaleX(var(--transform-scale-x)) scaleY(var(--transform-scale-y))}.origin-top-right{transform-origin:top right}.scale-95{--transform-scale-x:.95;--transform-scale-y:.95}.scale-100{--transform-scale-x:1;--transform-scale-y:1}.translate-x-0{--transform-translate-x:0}.-translate-x-full{--transform-translate-x:-100%}.translate-y-0{--transform-translate-y:0}.translate-y-4{--transform-translate-y:1rem}.transition-all{transition-property:all}.transition{transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform}.transition-opacity{transition-property:opacity}.ease-linear{transition-timing-function:linear}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-75{transition-duration:75ms}.duration-100{transition-duration:.1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@-webkit-keyframes spin{to{transform:rotate(1turn)}}@keyframes spin{to{transform:rotate(1turn)}}@-webkit-keyframes ping{75%,to{transform:scale(2);opacity:0}}@keyframes ping{75%,to{transform:scale(2);opacity:0}}@-webkit-keyframes pulse{50%{opacity:.5}}@keyframes pulse{50%{opacity:.5}}@-webkit-keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}@keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-spin{-webkit-animation:spin 1s linear infinite;animation:spin 1s linear infinite}@media (min-width:640px){.sm\:rounded-lg{border-radius:.5rem}.sm\:block{display:block}.sm\:inline-block{display:inline-block}.sm\:flex{display:flex}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:flex-row-reverse{flex-direction:row-reverse}.sm\:flex-no-wrap{flex-wrap:nowrap}.sm\:items-start{align-items:flex-start}.sm\:items-center{align-items:center}.sm\:justify-center{justify-content:center}.sm\:justify-between{justify-content:space-between}.sm\:flex-shrink-0{flex-shrink:0}.sm\:h-10{height:2.5rem}.sm\:h-screen{height:100vh}.sm\:mx-0{margin-left:0;margin-right:0}.sm\:my-8{margin-top:2rem;margin-bottom:2rem}.sm\:-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.sm\:mt-0{margin-top:0}.sm\:ml-3{margin-left:.75rem}.sm\:mt-4{margin-top:1rem}.sm\:ml-4{margin-left:1rem}.sm\:mt-6{margin-top:1.5rem}.sm\:ml-6{margin-left:1.5rem}.sm\:max-w-sm{max-width:24rem}.sm\:max-w-lg{max-width:32rem}.sm\:p-0{padding:0}.sm\:p-6{padding:1.5rem}.sm\:px-0{padding-left:0;padding-right:0}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:inset-0{top:0;right:0;bottom:0;left:0}.sm\:text-left{text-align:left}.sm\:align-middle{vertical-align:middle}.sm\:w-10{width:2.5rem}.sm\:w-auto{width:auto}.sm\:w-full{width:100%}.sm\:gap-4{grid-gap:1rem;gap:1rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-3{grid-column:span 3/span 3}.sm\:col-span-4{grid-column:span 4/span 4}.sm\:col-span-6{grid-column:span 6/span 6}.sm\:scale-95{--transform-scale-x:.95;--transform-scale-y:.95}.sm\:scale-100{--transform-scale-x:1;--transform-scale-y:1}.sm\:translate-y-0{--transform-translate-y:0}}@media (min-width:768px){.md\:block{display:block}.md\:flex{display:flex}.md\:grid{display:grid}.md\:hidden{display:none}.md\:justify-between{justify-content:space-between}.md\:flex-shrink-0{flex-shrink:0}.md\:text-sm{font-size:.875rem}.md\:mt-0{margin-top:0}.md\:mr-2{margin-right:.5rem}.md\:ml-2{margin-left:.5rem}.md\:ml-6{margin-left:1.5rem}.md\:mt-10{margin-top:2.5rem}.md\:-mr-1{margin-right:-.25rem}.md\:px-8{padding-left:2rem;padding-right:2rem}.md\:w-1\/2{width:50%}.md\:w-1\/3{width:33.333333%}.md\:gap-6{grid-gap:1.5rem;gap:1.5rem}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.md\:col-span-1{grid-column:span 1/span 1}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-6{grid-column:span 6/span 6}.md\:col-start-2{grid-column-start:2}.md\:col-start-4{grid-column-start:4}}@media (min-width:1024px){.lg\:rounded-lg{border-radius:.5rem}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:items-center{align-items:center}.lg\:h-screen{height:100vh}.lg\:-mx-8{margin-left:-2rem;margin-right:-2rem}.lg\:ml-16{margin-left:4rem}.lg\:mt-24{margin-top:6rem}.lg\:mt-48{margin-top:12rem}.lg\:p-0{padding:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:w-1\/2{width:50%}.lg\:w-1\/4{width:25%}.lg\:w-1\/5{width:20%}.lg\:gap-4{grid-gap:1rem;gap:1rem}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-4{grid-column:span 4/span 4}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-start-4{grid-column-start:4}.lg\:col-start-5{grid-column-start:5}} \ No newline at end of file +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}details{display:block}summary{display:list-item}[hidden],template{display:none}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}button{background-color:transparent;background-image:none}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}fieldset,ol,ul{margin:0;padding:0}ol,ul{list-style:none}html{font-family:Open Sans,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}*,:after,:before{box-sizing:border-box;border:0 solid #d2d6dc}hr{border-top-width:1px}img{border-style:solid}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#a0aec0}input:-ms-input-placeholder,textarea:-ms-input-placeholder{color:#a0aec0}input::placeholder,textarea::placeholder{color:#a0aec0}[role=button],button{cursor:pointer}table{border-collapse:collapse}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}button,input,optgroup,select,textarea{padding:0;line-height:inherit;color:inherit}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}.form-select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M7 7l3-3 3 3m0 6l-3 3-3-3' stroke='%239fa6b2' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;background-repeat:no-repeat;background-color:#fff;border-color:#d2d6dc;border-width:1px;border-radius:.375rem;padding:.5rem 2.5rem .5rem .75rem;font-size:1rem;line-height:1.5;background-position:right .5rem center;background-size:1.5em 1.5em}.form-select::-ms-expand{color:#9fa6b2;border:none}@media not print{.form-select::-ms-expand{display:none}}@media print and (-ms-high-contrast:active),print and (-ms-high-contrast:none){.form-select{padding-right:.75rem}}.form-select:focus{outline:none;box-shadow:0 0 0 3px rgba(164,202,254,.45);border-color:#a4cafe}.form-checkbox:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293z'/%3E%3C/svg%3E");border-color:transparent;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media not print{.form-checkbox::-ms-check{border-width:1px;color:transparent;background:inherit;border-color:inherit;border-radius:inherit}}.form-checkbox{-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#3f83f8;background-color:#fff;border-color:#d2d6dc;border-width:1px;border-radius:.25rem}.form-checkbox:focus{outline:none;box-shadow:0 0 0 3px rgba(164,202,254,.45);border-color:#a4cafe}.form-checkbox:checked:focus,.form-radio:checked{border-color:transparent}.form-radio:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E");background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media not print{.form-radio::-ms-check{border-width:1px;color:transparent;background:inherit;border-color:inherit;border-radius:inherit}}.form-radio{-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;flex-shrink:0;border-radius:100%;height:1rem;width:1rem;color:#3f83f8;background-color:#fff;border-color:#d2d6dc;border-width:1px}.form-radio:focus{outline:none;box-shadow:0 0 0 3px rgba(164,202,254,.45);border-color:#a4cafe}.form-radio:checked:focus{border-color:transparent}.button{border-radius:.25rem;padding:.75rem 1rem;font-size:.875rem;line-height:1rem;transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}button:disabled{opacity:.5;cursor:not-allowed}.button-primary{--text-opacity:1;color:#fff;color:rgba(255,255,255,var(--text-opacity))}.button-primary:hover{font-weight:600}.button-block{display:block;width:100%}.button-danger{--bg-opacity:1;background-color:#f05252;background-color:rgba(240,82,82,var(--bg-opacity));--text-opacity:1;color:#fff;color:rgba(255,255,255,var(--text-opacity))}.button-danger:hover{--bg-opacity:1;background-color:#e02424;background-color:rgba(224,36,36,var(--bg-opacity))}.button-secondary{--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity))}.button-secondary:hover{--bg-opacity:1;background-color:#e5e7eb;background-color:rgba(229,231,235,var(--bg-opacity))}.button-link:hover{font-weight:600;text-decoration:underline}.button-link:focus{outline:2px solid transparent;outline-offset:2px;text-decoration:underline}.validation{border-left-width:2px;margin-top:.5rem;margin-bottom:.25rem;--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity));padding:.25rem .75rem}.validation-fail{border-color:#f05252;border-color:rgba(240,82,82,var(--border-opacity))}.validation-fail,.validation-pass{--border-opacity:1;--text-opacity:1;color:#374151;color:rgba(55,65,81,var(--text-opacity));font-size:.875rem}.validation-pass{border-color:#0e9f6e;border-color:rgba(14,159,110,var(--border-opacity))}.input{align-items:center;border-width:1px;--border-opacity:1;border-color:#d2d6dc;border-color:rgba(210,214,220,var(--border-opacity));border-radius:.25rem;margin-top:.5rem;padding:.5rem 1rem;font-size:.875rem}.input:focus{outline:2px solid transparent;outline-offset:2px;--bg-opacity:1;background-color:#f9fafb;background-color:rgba(249,250,251,var(--bg-opacity))}.input-label{font-size:.875rem;--text-opacity:1;color:#4b5563;color:rgba(75,85,99,var(--text-opacity))}.input-slim{padding-top:.5rem;padding-bottom:.5rem}.alert{padding:.75rem 1rem;font-size:.875rem;border-left-width:2px;margin-top:.5rem;margin-bottom:.25rem;--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity));--border-opacity:1;border-color:#9fa6b2;border-color:rgba(159,166,178,var(--border-opacity))}.alert-success{--border-opacity:1;border-color:#0e9f6e;border-color:rgba(14,159,110,var(--border-opacity))}.alert-failure{--border-opacity:1;border-color:#f05252;border-color:rgba(240,82,82,var(--border-opacity))}.badge{display:inline-flex;align-items:center;padding:.125rem .625rem;border-radius:9999px;font-size:.75rem;font-weight:500;line-height:1rem}.badge-light{background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity));color:#252f3f;color:rgba(37,47,63,var(--text-opacity))}.badge-light,.badge-primary{--bg-opacity:1;--text-opacity:1}.badge-primary{background-color:#c3ddfd;background-color:rgba(195,221,253,var(--bg-opacity));color:#3f83f8;color:rgba(63,131,248,var(--text-opacity))}.badge-danger{background-color:#fde8e8;background-color:rgba(253,232,232,var(--bg-opacity));color:#f05252;color:rgba(240,82,82,var(--text-opacity))}.badge-danger,.badge-success{--bg-opacity:1;--text-opacity:1}.badge-success{background-color:#def7ec;background-color:rgba(222,247,236,var(--bg-opacity));color:#0e9f6e;color:rgba(14,159,110,var(--text-opacity))}.badge-secondary{--bg-opacity:1;background-color:#252f3f;background-color:rgba(37,47,63,var(--bg-opacity));--text-opacity:1;color:#e5e7eb;color:rgba(229,231,235,var(--text-opacity))}.badge-warning{background-color:#feecdc;background-color:rgba(254,236,220,var(--bg-opacity));color:#ff5a1f;color:rgba(255,90,31,var(--text-opacity))}.badge-info,.badge-warning{--bg-opacity:1;--text-opacity:1}.badge-info{background-color:#e1effe;background-color:rgba(225,239,254,var(--bg-opacity));color:#3f83f8;color:rgba(63,131,248,var(--text-opacity))}@media (min-width:640px){.dataTables_length{margin-top:1.25rem!important;margin-bottom:1.25rem!important}}@media (min-width:1024px){.dataTables_length{margin-top:1rem!important;margin-bottom:1rem!important}}.dataTables_length select{--bg-opacity:1!important;background-color:#fff!important;background-color:rgba(255,255,255,var(--bg-opacity))!important;align-items:center!important;border-width:1px!important;--border-opacity:1!important;border-color:#d2d6dc!important;border-color:rgba(210,214,220,var(--border-opacity))!important;border-radius:.25rem!important;margin-top:.5rem!important;font-size:.875rem!important;margin-left:.5rem!important;margin-right:.5rem!important;padding:.5rem!important}.dataTables_filter{margin-bottom:1rem}.dataTables_filter input{-webkit-appearance:none!important;-moz-appearance:none!important;appearance:none!important;background-color:#fff!important;border-radius:.375rem!important;font-size:1rem!important;line-height:1.5!important;align-items:center!important;border-width:1px!important;--border-opacity:1!important;border-color:#d2d6dc!important;border-color:rgba(210,214,220,var(--border-opacity))!important;border-radius:.25rem!important;margin-top:.5rem!important;padding:.5rem 1rem!important;font-size:.875rem!important}@media (min-width:1024px){.dataTables_filter{margin-top:-3rem!important}}.dataTables_paginate{padding-bottom:1.5rem!important;padding-top:.5rem!important}.dataTables_paginate .paginate_button{transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform!important;transition-duration:.15s!important;transition-timing-function:cubic-bezier(.4,0,.2,1)!important;--bg-opacity:1!important;background-color:#fff!important;background-color:rgba(255,255,255,var(--bg-opacity))!important;border-width:1px!important;--border-opacity:1!important;border-color:#d2d6dc!important;border-color:rgba(210,214,220,var(--border-opacity))!important;font-size:.875rem!important;line-height:1rem!important;font-weight:500!important;border-radius:.25rem!important;--text-opacity:1!important;color:#374151!important;color:rgba(55,65,81,var(--text-opacity))!important;margin-right:.25rem!important;padding:.5rem 1rem!important;cursor:pointer!important}.dataTables_paginate .current{--bg-opacity:1!important;background-color:#1c64f2!important;background-color:rgba(28,100,242,var(--bg-opacity))!important;--text-opacity:1!important;color:#fff!important;color:rgba(255,255,255,var(--text-opacity))!important}.dataTables_info{font-size:.875rem!important}.dataTables_empty{padding-top:1rem!important;padding-bottom:1rem!important}.pagination{display:flex!important;align-items:center!important}.pagination .page-link{margin-top:-1px!important;border-top-width:2px!important;border-color:transparent!important;padding-top:1rem!important;padding-left:1rem!important;padding-right:1rem!important;display:inline-flex!important;align-items:center!important;font-size:.875rem!important;line-height:1.25rem!important;font-weight:500!important;--text-opacity:1!important;color:#6b7280!important;color:rgba(107,114,128,var(--text-opacity))!important;transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform!important;transition-timing-function:cubic-bezier(.4,0,.2,1)!important;transition-duration:.15s!important;cursor:pointer!important}.pagination .page-link:hover{--text-opacity:1!important;color:#374151!important;color:rgba(55,65,81,var(--text-opacity))!important;--border-opacity:1!important;border-color:#d2d6dc!important;border-color:rgba(210,214,220,var(--border-opacity))!important}.pagination .page-link:focus{outline:2px solid transparent;outline-offset:2px;--text-opacity:1;color:#374151;color:rgba(55,65,81,var(--text-opacity));--border-opacity:1;border-color:#9fa6b2;border-color:rgba(159,166,178,var(--border-opacity))}.pagination .active>span{--text-opacity:1!important;color:#1c64f2!important;color:rgba(28,100,242,var(--text-opacity))!important;--border-opacity:1!important;border-color:#1c64f2!important;border-color:rgba(28,100,242,var(--border-opacity))!important}.space-x-1>:not(template)~:not(template){--space-x-reverse:0;margin-right:calc(0.25rem*var(--space-x-reverse));margin-left:calc(0.25rem*(1 - var(--space-x-reverse)))}.space-x-2>:not(template)~:not(template){--space-x-reverse:0;margin-right:calc(0.5rem*var(--space-x-reverse));margin-left:calc(0.5rem*(1 - var(--space-x-reverse)))}.bg-white{--bg-opacity:1;background-color:#fff;background-color:rgba(255,255,255,var(--bg-opacity))}.bg-gray-50{--bg-opacity:1;background-color:#f9fafb;background-color:rgba(249,250,251,var(--bg-opacity))}.bg-gray-100{--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity))}.bg-gray-500{--bg-opacity:1;background-color:#6b7280;background-color:rgba(107,114,128,var(--bg-opacity))}.bg-gray-600{--bg-opacity:1;background-color:#4b5563;background-color:rgba(75,85,99,var(--bg-opacity))}.bg-red-100{--bg-opacity:1;background-color:#fde8e8;background-color:rgba(253,232,232,var(--bg-opacity))}.bg-blue-50{--bg-opacity:1;background-color:#ebf5ff;background-color:rgba(235,245,255,var(--bg-opacity))}.bg-blue-600{--bg-opacity:1;background-color:#1c64f2;background-color:rgba(28,100,242,var(--bg-opacity))}.focus\:bg-gray-100:focus,.hover\:bg-gray-100:hover{--bg-opacity:1;background-color:#f4f5f7;background-color:rgba(244,245,247,var(--bg-opacity))}.focus\:bg-gray-600:focus{--bg-opacity:1;background-color:#4b5563;background-color:rgba(75,85,99,var(--bg-opacity))}.active\:bg-gray-50:active{--bg-opacity:1;background-color:#f9fafb;background-color:rgba(249,250,251,var(--bg-opacity))}.border-collapse{border-collapse:collapse}.border-black{--border-opacity:1;border-color:#000;border-color:rgba(0,0,0,var(--border-opacity))}.border-gray-100{--border-opacity:1;border-color:#f4f5f7;border-color:rgba(244,245,247,var(--border-opacity))}.border-gray-200{--border-opacity:1;border-color:#e5e7eb;border-color:rgba(229,231,235,var(--border-opacity))}.border-gray-300{--border-opacity:1;border-color:#d2d6dc;border-color:rgba(210,214,220,var(--border-opacity))}.border-red-300{--border-opacity:1;border-color:#f8b4b4;border-color:rgba(248,180,180,var(--border-opacity))}.border-red-400{--border-opacity:1;border-color:#f98080;border-color:rgba(249,128,128,var(--border-opacity))}.border-green-500{--border-opacity:1;border-color:#0e9f6e;border-color:rgba(14,159,110,var(--border-opacity))}.border-blue-500{--border-opacity:1;border-color:#3f83f8;border-color:rgba(63,131,248,var(--border-opacity))}.group:hover .group-hover\:border-transparent{border-color:transparent}.hover\:border-gray-800:hover{--border-opacity:1;border-color:#252f3f;border-color:rgba(37,47,63,var(--border-opacity))}.hover\:border-blue-600:hover{--border-opacity:1;border-color:#1c64f2;border-color:rgba(28,100,242,var(--border-opacity))}.focus\:border-blue-300:focus{--border-opacity:1;border-color:#a4cafe;border-color:rgba(164,202,254,var(--border-opacity))}.rounded-sm{border-radius:.125rem}.rounded{border-radius:.25rem}.rounded-md{border-radius:.375rem}.rounded-lg{border-radius:.5rem}.rounded-full{border-radius:9999px}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-0{border-width:0}.border-4{border-width:4px}.border{border-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-r{border-right-width:1px}.border-b{border-bottom-width:1px}.cursor-pointer{cursor:pointer}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.font-medium{font-weight:500}.font-semibold{font-weight:600}.font-bold{font-weight:700}.focus\:font-semibold:focus,.hover\:font-semibold:hover{font-weight:600}.h-0{height:0}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-12{height:3rem}.h-16{height:4rem}.h-32{height:8rem}.h-auto{height:auto}.h-screen{height:100vh}.text-xs{font-size:.75rem}.text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}.text-xl{font-size:1.25rem}.text-2xl{font-size:1.5rem}.text-3xl{font-size:1.875rem}.leading-4{line-height:1rem}.leading-5{line-height:1.25rem}.leading-6{line-height:1.5rem}.leading-9{line-height:2.25rem}.m-0{margin:0}.m-auto{margin:auto}.mx-2{margin-left:.5rem;margin-right:.5rem}.my-4{margin-top:1rem;margin-bottom:1rem}.mx-4{margin-left:1rem;margin-right:1rem}.my-10{margin-top:2.5rem;margin-bottom:2.5rem}.mx-auto{margin-left:auto;margin-right:auto}.-my-2{margin-top:-.5rem;margin-bottom:-.5rem}.mt-0{margin-top:0}.mb-0{margin-bottom:0}.ml-0{margin-left:0}.mt-1{margin-top:.25rem}.mr-1{margin-right:.25rem}.ml-1{margin-left:.25rem}.mt-2{margin-top:.5rem}.mr-2{margin-right:.5rem}.mb-2{margin-bottom:.5rem}.ml-2{margin-left:.5rem}.mt-3{margin-top:.75rem}.mr-3{margin-right:.75rem}.mb-3{margin-bottom:.75rem}.ml-3{margin-left:.75rem}.mt-4{margin-top:1rem}.mr-4{margin-right:1rem}.mb-4{margin-bottom:1rem}.ml-4{margin-left:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mb-6{margin-bottom:1.5rem}.mt-8{margin-top:2rem}.mt-10{margin-top:2.5rem}.mb-10{margin-bottom:2.5rem}.mt-16{margin-top:4rem}.-mr-1{margin-right:-.25rem}.-ml-1{margin-left:-.25rem}.-mt-4{margin-top:-1rem}.-ml-4{margin-left:-1rem}.-mr-14{margin-right:-3.5rem}.max-w-xs{max-width:20rem}.max-w-xl{max-width:36rem}.max-w-2xl{max-width:42rem}.min-h-screen{min-height:100vh}.min-w-full{min-width:100%}.object-cover{-o-object-fit:cover;object-fit:cover}.opacity-0{opacity:0}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.opacity-100{opacity:1}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-scroll{overflow-y:scroll}.p-1{padding:.25rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-8{padding:2rem}.p-10{padding:2.5rem}.py-0{padding-top:0;padding-bottom:0}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-4{padding-left:1rem;padding-right:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.pl-0{padding-left:0}.pt-4{padding-top:1rem}.pr-4{padding-right:1rem}.pb-4{padding-bottom:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pb-20{padding-bottom:5rem}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{right:0;left:0}.inset-0,.inset-y-0{top:0;bottom:0}.inset-x-0{right:0;left:0}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.shadow-xs{box-shadow:0 0 0 1px rgba(0,0,0,.05)}.shadow-sm{box-shadow:0 1px 2px 0 rgba(0,0,0,.05)}.shadow{box-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px 0 rgba(0,0,0,.06)}.shadow-lg{box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05)}.shadow-xl{box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 10px 10px -5px rgba(0,0,0,.04)}.hover\:shadow-lg:hover{box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05)}.focus\:shadow-outline:focus{box-shadow:0 0 0 3px rgba(118,169,250,.45)}.focus\:shadow-outline-blue:focus{box-shadow:0 0 0 3px rgba(164,202,254,.45)}.fill-current{fill:currentColor}.table-auto{table-layout:auto}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-white{--text-opacity:1;color:#fff;color:rgba(255,255,255,var(--text-opacity))}.text-black{--text-opacity:1;color:#000;color:rgba(0,0,0,var(--text-opacity))}.text-gray-300{--text-opacity:1;color:#d2d6dc;color:rgba(210,214,220,var(--text-opacity))}.text-gray-400{--text-opacity:1;color:#9fa6b2;color:rgba(159,166,178,var(--text-opacity))}.text-gray-500{--text-opacity:1;color:#6b7280;color:rgba(107,114,128,var(--text-opacity))}.text-gray-600{--text-opacity:1;color:#4b5563;color:rgba(75,85,99,var(--text-opacity))}.text-gray-700{--text-opacity:1;color:#374151;color:rgba(55,65,81,var(--text-opacity))}.text-gray-800{--text-opacity:1;color:#252f3f;color:rgba(37,47,63,var(--text-opacity))}.text-gray-900{--text-opacity:1;color:#161e2e;color:rgba(22,30,46,var(--text-opacity))}.text-red-400{--text-opacity:1;color:#f98080;color:rgba(249,128,128,var(--text-opacity))}.text-red-600{--text-opacity:1;color:#e02424;color:rgba(224,36,36,var(--text-opacity))}.text-green-600{--text-opacity:1;color:#057a55;color:rgba(5,122,85,var(--text-opacity))}.text-blue-600{--text-opacity:1;color:#1c64f2;color:rgba(28,100,242,var(--text-opacity))}.hover\:text-gray-300:hover{--text-opacity:1;color:#d2d6dc;color:rgba(210,214,220,var(--text-opacity))}.hover\:text-gray-500:hover{--text-opacity:1;color:#6b7280;color:rgba(107,114,128,var(--text-opacity))}.hover\:text-gray-600:hover{--text-opacity:1;color:#4b5563;color:rgba(75,85,99,var(--text-opacity))}.hover\:text-gray-700:hover{--text-opacity:1;color:#374151;color:rgba(55,65,81,var(--text-opacity))}.hover\:text-gray-800:hover{--text-opacity:1;color:#252f3f;color:rgba(37,47,63,var(--text-opacity))}.hover\:text-gray-900:hover{--text-opacity:1;color:#161e2e;color:rgba(22,30,46,var(--text-opacity))}.hover\:text-blue-600:hover{--text-opacity:1;color:#1c64f2;color:rgba(28,100,242,var(--text-opacity))}.hover\:text-indigo-900:hover{--text-opacity:1;color:#362f78;color:rgba(54,47,120,var(--text-opacity))}.focus\:text-gray-500:focus{--text-opacity:1;color:#6b7280;color:rgba(107,114,128,var(--text-opacity))}.focus\:text-gray-600:focus{--text-opacity:1;color:#4b5563;color:rgba(75,85,99,var(--text-opacity))}.focus\:text-gray-900:focus{--text-opacity:1;color:#161e2e;color:rgba(22,30,46,var(--text-opacity))}.active\:text-gray-800:active{--text-opacity:1;color:#252f3f;color:rgba(37,47,63,var(--text-opacity))}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.underline{text-decoration:underline}.line-through{text-decoration:line-through}.focus\:underline:focus,.hover\:underline:hover{text-decoration:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.align-middle{vertical-align:middle}.align-bottom{vertical-align:bottom}.whitespace-no-wrap{white-space:nowrap}.break-words{word-wrap:break-word;overflow-wrap:break-word}.break-all{word-break:break-all}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.w-0{width:0}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-48{width:12rem}.w-56{width:14rem}.w-64{width:16rem}.w-auto{width:auto}.w-1\/2{width:50%}.w-full{width:100%}.z-0{z-index:0}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.gap-4{grid-gap:1rem;gap:1rem}.gap-5{grid-gap:1.25rem;gap:1.25rem}.gap-6{grid-gap:1.5rem;gap:1.5rem}.gap-8{grid-gap:2rem;gap:2rem}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.col-span-1{grid-column:span 1/span 1}.col-span-2{grid-column:span 2/span 2}.col-span-4{grid-column:span 4/span 4}.col-span-6{grid-column:span 6/span 6}.col-span-7{grid-column:span 7/span 7}.col-span-12{grid-column:span 12/span 12}.col-start-3{grid-column-start:3}.transform{--transform-translate-x:0;--transform-translate-y:0;--transform-rotate:0;--transform-skew-x:0;--transform-skew-y:0;--transform-scale-x:1;--transform-scale-y:1;transform:translateX(var(--transform-translate-x)) translateY(var(--transform-translate-y)) rotate(var(--transform-rotate)) skewX(var(--transform-skew-x)) skewY(var(--transform-skew-y)) scaleX(var(--transform-scale-x)) scaleY(var(--transform-scale-y))}.origin-top-right{transform-origin:top right}.scale-95{--transform-scale-x:.95;--transform-scale-y:.95}.scale-100{--transform-scale-x:1;--transform-scale-y:1}.translate-x-0{--transform-translate-x:0}.-translate-x-full{--transform-translate-x:-100%}.translate-y-0{--transform-translate-y:0}.translate-y-4{--transform-translate-y:1rem}.transition-all{transition-property:all}.transition{transition-property:background-color,border-color,color,fill,stroke,opacity,box-shadow,transform}.transition-opacity{transition-property:opacity}.ease-linear{transition-timing-function:linear}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-75{transition-duration:75ms}.duration-100{transition-duration:.1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@-webkit-keyframes spin{to{transform:rotate(1turn)}}@keyframes spin{to{transform:rotate(1turn)}}@-webkit-keyframes ping{75%,to{transform:scale(2);opacity:0}}@keyframes ping{75%,to{transform:scale(2);opacity:0}}@-webkit-keyframes pulse{50%{opacity:.5}}@keyframes pulse{50%{opacity:.5}}@-webkit-keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}@keyframes bounce{0%,to{transform:translateY(-25%);-webkit-animation-timing-function:cubic-bezier(.8,0,1,1);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;-webkit-animation-timing-function:cubic-bezier(0,0,.2,1);animation-timing-function:cubic-bezier(0,0,.2,1)}}.animate-spin{-webkit-animation:spin 1s linear infinite;animation:spin 1s linear infinite}@media (min-width:640px){.sm\:rounded-lg{border-radius:.5rem}.sm\:block{display:block}.sm\:inline-block{display:inline-block}.sm\:flex{display:flex}.sm\:grid{display:grid}.sm\:hidden{display:none}.sm\:flex-row-reverse{flex-direction:row-reverse}.sm\:flex-no-wrap{flex-wrap:nowrap}.sm\:items-start{align-items:flex-start}.sm\:items-center{align-items:center}.sm\:justify-center{justify-content:center}.sm\:justify-between{justify-content:space-between}.sm\:flex-shrink-0{flex-shrink:0}.sm\:h-10{height:2.5rem}.sm\:h-screen{height:100vh}.sm\:mx-0{margin-left:0;margin-right:0}.sm\:my-8{margin-top:2rem;margin-bottom:2rem}.sm\:-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.sm\:mt-0{margin-top:0}.sm\:ml-3{margin-left:.75rem}.sm\:mt-4{margin-top:1rem}.sm\:ml-4{margin-left:1rem}.sm\:mt-6{margin-top:1.5rem}.sm\:ml-6{margin-left:1.5rem}.sm\:max-w-sm{max-width:24rem}.sm\:max-w-lg{max-width:32rem}.sm\:p-0{padding:0}.sm\:p-6{padding:1.5rem}.sm\:px-0{padding-left:0;padding-right:0}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:inset-0{top:0;right:0;bottom:0;left:0}.sm\:text-left{text-align:left}.sm\:align-middle{vertical-align:middle}.sm\:w-10{width:2.5rem}.sm\:w-auto{width:auto}.sm\:w-full{width:100%}.sm\:gap-4{grid-gap:1rem;gap:1rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-3{grid-column:span 3/span 3}.sm\:col-span-4{grid-column:span 4/span 4}.sm\:col-span-6{grid-column:span 6/span 6}.sm\:scale-95{--transform-scale-x:.95;--transform-scale-y:.95}.sm\:scale-100{--transform-scale-x:1;--transform-scale-y:1}.sm\:translate-y-0{--transform-translate-y:0}}@media (min-width:768px){.md\:block{display:block}.md\:flex{display:flex}.md\:grid{display:grid}.md\:hidden{display:none}.md\:justify-between{justify-content:space-between}.md\:flex-shrink-0{flex-shrink:0}.md\:text-sm{font-size:.875rem}.md\:mt-0{margin-top:0}.md\:mr-2{margin-right:.5rem}.md\:ml-2{margin-left:.5rem}.md\:ml-6{margin-left:1.5rem}.md\:mt-10{margin-top:2.5rem}.md\:-mr-1{margin-right:-.25rem}.md\:px-8{padding-left:2rem;padding-right:2rem}.md\:w-1\/2{width:50%}.md\:w-1\/3{width:33.333333%}.md\:gap-6{grid-gap:1.5rem;gap:1.5rem}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.md\:col-span-1{grid-column:span 1/span 1}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-6{grid-column:span 6/span 6}.md\:col-start-2{grid-column-start:2}.md\:col-start-4{grid-column-start:4}}@media (min-width:1024px){.lg\:rounded-lg{border-radius:.5rem}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:items-center{align-items:center}.lg\:h-screen{height:100vh}.lg\:-mx-8{margin-left:-2rem;margin-right:-2rem}.lg\:ml-16{margin-left:4rem}.lg\:mt-24{margin-top:6rem}.lg\:mt-48{margin-top:12rem}.lg\:p-0{padding:0}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:w-1\/2{width:50%}.lg\:w-1\/4{width:25%}.lg\:w-1\/5{width:20%}.lg\:gap-4{grid-gap:1rem;gap:1rem}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-4{grid-column:span 4/span 4}.lg\:col-span-6{grid-column:span 6/span 6}.lg\:col-start-4{grid-column-start:4}.lg\:col-start-5{grid-column-start:5}} \ No newline at end of file diff --git a/public/js/clients/payments/braintree-credit-card.js b/public/js/clients/payments/braintree-credit-card.js new file mode 100644 index 000000000000..48ffc19c1638 --- /dev/null +++ b/public/js/clients/payments/braintree-credit-card.js @@ -0,0 +1,2 @@ +/*! For license information please see braintree-credit-card.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=17)}({17:function(e,t,n){e.exports=n("jPAV")},jPAV:function(e,t){function n(e,t){for(var n=0;n { + dropinInstance.requestPaymentMethod((error, payload) => { + if (error) { + return console.error(error); + } + + payNow.disabled = true; + + payNow.querySelector('svg').classList.remove('hidden'); + payNow.querySelector('span').classList.add('hidden'); + + document.querySelector('input[name=gateway_response]').value = JSON.stringify(payload); + + let tokenBillingCheckbox = document.querySelector( + 'input[name="token-billing-checkbox"]:checked' + ); + + if (tokenBillingCheckbox) { + document.querySelector('input[name="store_card"]').value = + tokenBillingCheckbox.value; + } + + document.getElementById('server-response').submit(); + }); + }); + } + + handle() { + this.initBraintreeDataCollector(); + this.mountBraintreePaymentWidget(); + + Array + .from(document.getElementsByClassName('toggle-payment-with-token')) + .forEach((element) => element.addEventListener('click', (element) => { + document.getElementById('dropin-container').classList.add('hidden'); + document.getElementById('save-card--container').style.display = 'none'; + document.querySelector('input[name=token]').value = element.target.dataset.token; + + document.getElementById('pay-now-with-token').classList.remove('hidden'); + document.getElementById('pay-now').classList.add('hidden'); + })); + + document + .getElementById('toggle-payment-with-credit-card') + .addEventListener('click', (element) => { + document.getElementById('dropin-container').classList.remove('hidden'); + document.getElementById('save-card--container').style.display = 'grid'; + document.querySelector('input[name=token]').value = ""; + + document.getElementById('pay-now-with-token').classList.add('hidden'); + document.getElementById('pay-now').classList.remove('hidden'); + }); + + let payNowWithToken = document.getElementById('pay-now-with-token'); + + payNowWithToken + .addEventListener('click', (element) => { + payNowWithToken.disabled = true; + payNowWithToken.querySelector('svg').classList.remove('hidden'); + payNowWithToken.querySelector('span').classList.add('hidden'); + + document.getElementById('server-response').submit(); + }); + } +} + +new BraintreeCreditCard().handle(); diff --git a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php index 2d82adcf33b9..ea775a3016b3 100644 --- a/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php @@ -70,107 +70,5 @@ @endsection @section('gateway_footer') - + @endsection diff --git a/webpack.mix.js b/webpack.mix.js index ef423fb2c701..f9ad1769f67a 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -65,6 +65,10 @@ mix.js("resources/js/app.js", "public/js") .js( "resources/js/clients/linkify-urls.js", "public/js/clients/linkify-urls.js" + ) + .js( + "resources/js/clients/payments/braintree-credit-card.js", + "public/js/clients/payments/braintree-credit-card.js" ); mix.copyDirectory('node_modules/card-js/card-js.min.css', 'public/css/card-js.min.css'); From f1c4b1cdb3cfe0c449aad0fc1dbdbd0a961a478d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Sat, 1 May 2021 17:53:39 +0200 Subject: [PATCH 09/26] wip --- app/PaymentDrivers/Braintree/CreditCard.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php index 88359029dc5b..62cb5b650ef8 100644 --- a/app/PaymentDrivers/Braintree/CreditCard.php +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -86,9 +86,11 @@ class CreditCard $customer = $this->braintree->findOrCreateCustomer(); + $token = $this->getPaymentToken($request->all(), $customer->id); + $result = $this->braintree->gateway->transaction()->sale([ 'amount' => $this->braintree->payment_hash->data->amount_with_fee, - 'paymentMethodToken' => $this->getPaymentToken($request->all(), $customer->id), + 'paymentMethodToken' => $token, 'deviceData' => $state['client-data'], 'options' => [ 'submitForSettlement' => true @@ -99,6 +101,8 @@ class CreditCard $this->braintree->logSuccessfulGatewayResponse(['response' => $request->server_response, 'data' => $this->braintree->payment_hash], SystemLog::TYPE_BRAINTREE); if ($request->store_card && is_null($request->token)) { + $payment_method = $this->braintree->gateway->paymentMethod()->find($token); + $this->storePaymentMethod($payment_method, $customer->id); } @@ -185,15 +189,15 @@ class CreditCard { try { $payment_meta = new \stdClass; - $payment_meta->exp_month = (string)$method->paymentMethod->expirationMonth; - $payment_meta->exp_year = (string)$method->paymentMethod->expirationYear; - $payment_meta->brand = (string)$method->paymentMethod->cardType; - $payment_meta->last4 = (string)$method->paymentMethod->last4; + $payment_meta->exp_month = (string)$method->expirationMonth; + $payment_meta->exp_year = (string)$method->expirationYear; + $payment_meta->brand = (string)$method->cardType; + $payment_meta->last4 = (string)$method->last4; $payment_meta->type = GatewayType::CREDIT_CARD; $data = [ 'payment_meta' => $payment_meta, - 'token' => $method->paymentMethod->token, + 'token' => $method->token, 'payment_method_id' => $this->braintree->payment_hash->data->payment_method_id, ]; From 1d4e2bcabe4c9a567082783b47e0fdd9d87655be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Sat, 1 May 2021 22:03:07 +0200 Subject: [PATCH 10/26] Fix passing correct amount to PaymentFailureMailer --- app/PaymentDrivers/Braintree/CreditCard.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/PaymentDrivers/Braintree/CreditCard.php b/app/PaymentDrivers/Braintree/CreditCard.php index 62cb5b650ef8..0006ea42cbd8 100644 --- a/app/PaymentDrivers/Braintree/CreditCard.php +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -160,7 +160,7 @@ class CreditCard */ private function processUnsuccessfulPayment($response) { - PaymentFailureMailer::dispatch($this->braintree->client, $response->transaction->additionalProcessorResponse, $this->braintree->client->company, 10); + PaymentFailureMailer::dispatch($this->braintree->client, $response->transaction->additionalProcessorResponse, $this->braintree->client->company, $this->braintree->payment_hash->data->amount_with_fee); PaymentFailureMailer::dispatch( $this->braintree->client, From 15beb26700b024dd5fe9735228d18252b634832d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Sat, 1 May 2021 22:03:28 +0200 Subject: [PATCH 11/26] Auto billing feature for credit cards using Braintree --- app/PaymentDrivers/BraintreePaymentDriver.php | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index a9c09523a4b9..18abc1c8fa30 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -14,8 +14,13 @@ namespace App\PaymentDrivers; use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest; +use App\Jobs\Mail\PaymentFailureMailer; +use App\Jobs\Util\SystemLogger; use App\Models\ClientGatewayToken; use App\Models\GatewayType; +use App\Models\Invoice; +use App\Models\PaymentHash; +use App\Models\PaymentType; use App\Models\SystemLog; use App\PaymentDrivers\Braintree\CreditCard; use Illuminate\Http\Request; @@ -98,7 +103,7 @@ class BraintreePaymentDriver extends BaseDriver return $this->gateway->customer()->find($existing->gateway_customer_reference); } - $result = $this->gateway->customer()->create([ + $result = $this->gateway->customer()->create([ 'firstName' => $this->client->present()->name, 'email' => $this->client->present()->email, 'phone' => $this->client->present()->phone, @@ -108,4 +113,72 @@ class BraintreePaymentDriver extends BaseDriver return $result->customer; } } + + public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash) + { + $amount = array_sum(array_column($payment_hash->invoices(), 'amount')) + $payment_hash->fee_total; + + $invoice = Invoice::whereIn('id', $this->transformKeys(array_column($payment_hash->invoices(), 'invoice_id')))->first(); + + if ($invoice) { + $description = "Invoice {$invoice->number} for {$amount} for client {$this->client->present()->name()}"; + } else { + $description = "Payment with no invoice for amount {$amount} for client {$this->client->present()->name()}"; + } + + $this->init(); + + $result = $this->gateway->transaction()->sale([ + 'amount' => $amount, + 'paymentMethodToken' => $cgt->token, + 'deviceData' => '', + 'options' => [ + 'submitForSettlement' => true + ], + ]); + + if ($result->success) { + $this->confirmGatewayFee(); + + $data = [ + 'payment_type' => PaymentType::parseCardType(strtolower($result->transaction->creditCard['cardType'])), + 'amount' => $amount, + 'transaction_reference' => $result->transaction->id, + 'gateway_type_id' => GatewayType::CREDIT_CARD, + ]; + + $payment = $this->createPayment($data, \App\Models\Payment::STATUS_COMPLETED); + + SystemLogger::dispatch( + ['response' => $result, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_BRAINTREE, + $this->client + ); + + return $payment; + } + + if (! $result->success) { + $this->unWindGatewayFees($payment_hash); + + PaymentFailureMailer::dispatch($this->client, $result->transaction->additionalProcessorResponse, $this->client->company, $this->payment_hash->data->amount_with_fee); + + $message = [ + 'server_response' => $result, + 'data' => $this->payment_hash->data, + ]; + + SystemLogger::dispatch( + $message, + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_BRAINTREE, + $this->client + ); + + return false; + } + } } From 6992216117c0a9e2229790e32f84c92d40020ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Mon, 3 May 2021 14:10:46 +0200 Subject: [PATCH 12/26] wip --- app/PaymentDrivers/Braintree/PayPal.php | 36 +++++++ app/PaymentDrivers/BraintreePaymentDriver.php | 3 +- .../gateways/braintree/paypal/pay.blade.php | 98 +++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 app/PaymentDrivers/Braintree/PayPal.php create mode 100644 resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php diff --git a/app/PaymentDrivers/Braintree/PayPal.php b/app/PaymentDrivers/Braintree/PayPal.php new file mode 100644 index 000000000000..747ce562cc99 --- /dev/null +++ b/app/PaymentDrivers/Braintree/PayPal.php @@ -0,0 +1,36 @@ +braintree = $braintree; + + $this->braintree->init(); + } + + /** + * Credit card payment page. + * + * @param array $data + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function paymentView(array $data) + { + $data['gateway'] = $this->braintree; + $data['client_token'] = $this->braintree->gateway->clientToken()->generate(); + + return render('gateways.braintree.paypal.pay', $data); + } +} diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index 18abc1c8fa30..719a44ae07d4 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -23,6 +23,7 @@ use App\Models\PaymentHash; use App\Models\PaymentType; use App\Models\SystemLog; use App\PaymentDrivers\Braintree\CreditCard; +use App\PaymentDrivers\Braintree\PayPal; use Illuminate\Http\Request; class BraintreePaymentDriver extends BaseDriver @@ -59,7 +60,7 @@ class BraintreePaymentDriver extends BaseDriver { $class = self::$methods[$payment_method_id]; - $this->payment_method = new $class($this); + $this->payment_method = new PayPal($this); return $this; } diff --git a/resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php new file mode 100644 index 000000000000..8103930fc3a6 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php @@ -0,0 +1,98 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.paypal'), 'card_title' => ctrans('texts.paypal')]) + +@section('gateway_head') + + + + +@endsection + +@section('gateway_content') +
+ @csrf + + + + + + + + + +
+ + + + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')]) + {{ ctrans('texts.paypal') }} + @endcomponent + + @include('portal.ninja2020.gateways.includes.payment_details') + + @component('portal.ninja2020.components.general.card-element-single') +
+ @endcomponent +@endsection + +@section('gateway_footer') + +@endsection From e6697e183c389080b53004c7cd5afda4e250b300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Mon, 3 May 2021 15:48:53 +0200 Subject: [PATCH 13/26] Crypress: Payment with new card --- app/PaymentDrivers/BraintreePaymentDriver.php | 2 +- cypress.json | 6 ++- .../gateways/braintree_credit_card.spec.js | 47 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 cypress/integration/gateways/braintree_credit_card.spec.js diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index 719a44ae07d4..075eee3251ad 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -60,7 +60,7 @@ class BraintreePaymentDriver extends BaseDriver { $class = self::$methods[$payment_method_id]; - $this->payment_method = new PayPal($this); + $this->payment_method = new $class($this); return $this; } diff --git a/cypress.json b/cypress.json index 9741c8963d7e..fa8e22edf8d1 100644 --- a/cypress.json +++ b/cypress.json @@ -1,8 +1,10 @@ { "video": false, - "baseUrl": "https://localhost:8080/", + "baseUrl": "http://localhost:8080/", "chromeWebSecurity": false, "env": { "runningEnvironment": "docker" - } + }, + "viewportWidth": 1280, + "viewportHeight": 800 } diff --git a/cypress/integration/gateways/braintree_credit_card.spec.js b/cypress/integration/gateways/braintree_credit_card.spec.js new file mode 100644 index 000000000000..1f0f30e69262 --- /dev/null +++ b/cypress/integration/gateways/braintree_credit_card.spec.js @@ -0,0 +1,47 @@ +context('Checkout.com: Credit card testing', () => { + beforeEach(() => { + cy.clientLogin(); + }); + + afterEach(() => { + cy.visit('/client/logout'); + }); + + it('should not be able to add payment method', function () { + cy.visit('/client/payment_methods'); + + cy.get('[data-cy=add-payment-method]').click(); + cy.get('[data-cy=add-credit-card-link]').click(); + + cy.get('[data-ref=gateway-container]') + .contains('This payment method can be can saved for future use, once you complete your first transaction. Don\'t forget to check "Store credit card details" during payment process.'); + }); + + it('should pay with new card', function () { + cy.visit('/client/invoices'); + + cy.get('[data-cy=pay-now]').first().click(); + cy.get('[data-cy=pay-now-dropdown]').click(); + cy.get('[data-cy=pay-with-0]').click(); + + cy + .get('#braintree-hosted-field-number') + .wait(5000) + .iframeLoaded() + .its('document') + .getInDocument('#credit-card-number') + .type(4111111111111111) + + cy + .get('#braintree-hosted-field-expirationDate') + .wait(5000) + .iframeLoaded() + .its('document') + .getInDocument('#expiration') + .type(1224) + + cy.get('#pay-now').click(); + + cy.url().should('contain', '/client/payments/VolejRejNm'); + }); +}); From 3d6f256e33211833b78088367f653b75ffa6c935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Mon, 3 May 2021 15:56:55 +0200 Subject: [PATCH 14/26] Cypress: Paying with existing card & removing the payment method --- .../gateways/braintree_credit_card.spec.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cypress/integration/gateways/braintree_credit_card.spec.js b/cypress/integration/gateways/braintree_credit_card.spec.js index 1f0f30e69262..967041045034 100644 --- a/cypress/integration/gateways/braintree_credit_card.spec.js +++ b/cypress/integration/gateways/braintree_credit_card.spec.js @@ -44,4 +44,32 @@ context('Checkout.com: Credit card testing', () => { cy.url().should('contain', '/client/payments/VolejRejNm'); }); + + it('should pay with saved card (token)', function () { + cy.visit('/client/invoices'); + + cy.get('[data-cy=pay-now]').first().click(); + cy.get('[data-cy=pay-now-dropdown]').click(); + cy.get('[data-cy=pay-with-0]').click(); + + cy.get('[name=payment-type]').first().check(); + + cy.get('#pay-now-with-token').click(); + + cy.url().should('contain', '/client/payments/Opnel5aKBz'); + }); + + it('should be able to remove payment method', function () { + cy.visit('/client/payment_methods'); + + cy.get('[data-cy=view-payment-method]').click(); + + cy.get('#open-delete-popup').click(); + + cy.get('[data-cy=confirm-payment-removal]').click(); + + cy.url().should('contain', '/client/payment_methods'); + + cy.get('body').contains('Payment method has been successfully removed.'); + }); }); From a3099b5cde05be6a3a9400cd6a9e58c6e9be2310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Mon, 3 May 2021 17:25:03 +0200 Subject: [PATCH 15/26] Fixes for tests --- cypress/integration/client_portal/credits.spec.js | 2 +- cypress/integration/client_portal/invoices.spec.js | 2 +- cypress/integration/client_portal/payment_methods.spec.js | 2 +- cypress/integration/client_portal/payments.spec.js | 2 +- cypress/integration/client_portal/quotes.spec.js | 2 +- cypress/integration/client_portal/recurring_invoices.spec.js | 3 ++- .../ninja2020/components/general/sidebar/header.blade.php | 4 ++-- .../ninja2020/components/general/sidebar/main.blade.php | 1 + 8 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cypress/integration/client_portal/credits.spec.js b/cypress/integration/client_portal/credits.spec.js index 41e94ca8eabc..ca51088147dd 100644 --- a/cypress/integration/client_portal/credits.spec.js +++ b/cypress/integration/client_portal/credits.spec.js @@ -14,7 +14,7 @@ describe('Credits', () => { cy.visit('/client/credits'); cy.get('body') - .find('span') + .find('[data-ref=meta-title]') .first() .should('contain.text', 'Credits'); }); diff --git a/cypress/integration/client_portal/invoices.spec.js b/cypress/integration/client_portal/invoices.spec.js index 41d798874a3d..8f1c9d28339d 100644 --- a/cypress/integration/client_portal/invoices.spec.js +++ b/cypress/integration/client_portal/invoices.spec.js @@ -14,7 +14,7 @@ context('Invoices', () => { cy.visit('/client/invoices'); cy.get('body') - .find('span') + .find('[data-ref=meta-title]') .first() .should('contain.text', 'Invoices'); }); diff --git a/cypress/integration/client_portal/payment_methods.spec.js b/cypress/integration/client_portal/payment_methods.spec.js index 4c87e5547d04..69a682d4ac92 100644 --- a/cypress/integration/client_portal/payment_methods.spec.js +++ b/cypress/integration/client_portal/payment_methods.spec.js @@ -14,7 +14,7 @@ context('Payment methods', () => { cy.visit('/client/payment_methods'); cy.get('body') - .find('span') + .find('[data-ref=meta-title]') .first() .should('contain.text', 'Payment Method'); }); diff --git a/cypress/integration/client_portal/payments.spec.js b/cypress/integration/client_portal/payments.spec.js index e569acc86507..aa735b970999 100644 --- a/cypress/integration/client_portal/payments.spec.js +++ b/cypress/integration/client_portal/payments.spec.js @@ -14,7 +14,7 @@ context('Payments', () => { cy.visit('/client/payments'); cy.get('body') - .find('span') + .find('[data-ref=meta-title]') .first() .should('contain.text', 'Payments'); }); diff --git a/cypress/integration/client_portal/quotes.spec.js b/cypress/integration/client_portal/quotes.spec.js index 2b2918e48149..538b2fd558eb 100644 --- a/cypress/integration/client_portal/quotes.spec.js +++ b/cypress/integration/client_portal/quotes.spec.js @@ -14,7 +14,7 @@ describe('Quotes', () => { cy.visit('/client/quotes'); cy.get('body') - .find('span') + .find('[data-ref=meta-title]') .first() .should('contain.text', 'Quotes'); }); diff --git a/cypress/integration/client_portal/recurring_invoices.spec.js b/cypress/integration/client_portal/recurring_invoices.spec.js index 5041301d4a06..7fae096d784a 100644 --- a/cypress/integration/client_portal/recurring_invoices.spec.js +++ b/cypress/integration/client_portal/recurring_invoices.spec.js @@ -14,7 +14,8 @@ context('Recurring invoices', () => { it('should show reucrring invoices text', () => { cy.visit('/client/recurring_invoices'); - cy.get('span') + cy.get('body') + .find('[data-ref=meta-title]') .first() .should('contain.text', 'Recurring Invoices'); }); diff --git a/resources/views/portal/ninja2020/components/general/sidebar/header.blade.php b/resources/views/portal/ninja2020/components/general/sidebar/header.blade.php index a5e0a6f8e334..3fdf7251fc38 100644 --- a/resources/views/portal/ninja2020/components/general/sidebar/header.blade.php +++ b/resources/views/portal/ninja2020/components/general/sidebar/header.blade.php @@ -5,7 +5,7 @@
- @yield('meta_title') + @yield('meta_title')
@if($multiple_contacts->count() > 1)
@@ -23,7 +23,7 @@
@foreach($multiple_contacts as $contact) - {{ $contact->company->present()->name }} + {{ $contact->company->present()->name }} @endforeach
diff --git a/resources/views/portal/ninja2020/components/general/sidebar/main.blade.php b/resources/views/portal/ninja2020/components/general/sidebar/main.blade.php index cd7fcca31ee6..b907fe6a9f08 100644 --- a/resources/views/portal/ninja2020/components/general/sidebar/main.blade.php +++ b/resources/views/portal/ninja2020/components/general/sidebar/main.blade.php @@ -26,6 +26,7 @@
@includeWhen(session()->has('success'), 'portal.ninja2020.components.general.messages.success') + {{ $slot }}
From bb0f8aff2cb20f8a5220550de834e44db2085433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Mon, 3 May 2021 17:36:02 +0200 Subject: [PATCH 16/26] Make Braintree visible in migrations --- ...152940_make_braintree_provider_visible.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 database/migrations/2021_05_03_152940_make_braintree_provider_visible.php diff --git a/database/migrations/2021_05_03_152940_make_braintree_provider_visible.php b/database/migrations/2021_05_03_152940_make_braintree_provider_visible.php new file mode 100644 index 000000000000..7d7072a40f25 --- /dev/null +++ b/database/migrations/2021_05_03_152940_make_braintree_provider_visible.php @@ -0,0 +1,31 @@ +first(); + $gateway->visible = 1; + $gateway->save(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // + } +} From c22bf5bd10573df3e66a1f235d5a354d8af84fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Tue, 4 May 2021 12:19:06 +0200 Subject: [PATCH 17/26] Set Braintree visible in the PaymentLibrariesSeeder --- .../2021_05_03_152940_make_braintree_provider_visible.php | 4 +--- database/seeders/PaymentLibrariesSeeder.php | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/database/migrations/2021_05_03_152940_make_braintree_provider_visible.php b/database/migrations/2021_05_03_152940_make_braintree_provider_visible.php index 7d7072a40f25..f027712df581 100644 --- a/database/migrations/2021_05_03_152940_make_braintree_provider_visible.php +++ b/database/migrations/2021_05_03_152940_make_braintree_provider_visible.php @@ -14,9 +14,7 @@ class MakeBraintreeProviderVisible extends Migration */ public function up() { - $gateway = Gateway::where('key', 'f7ec488676d310683fb51802d076d713')->first(); - $gateway->visible = 1; - $gateway->save(); + Gateway::where('id', 50)->update(['visible' => 1]); } /** diff --git a/database/seeders/PaymentLibrariesSeeder.php b/database/seeders/PaymentLibrariesSeeder.php index 3f358f131534..ef592ec64a50 100644 --- a/database/seeders/PaymentLibrariesSeeder.php +++ b/database/seeders/PaymentLibrariesSeeder.php @@ -95,7 +95,7 @@ class PaymentLibrariesSeeder extends Seeder Gateway::query()->update(['visible' => 0]); - Gateway::whereIn('id', [1,15,20,39,55])->update(['visible' => 1]); + Gateway::whereIn('id', [1,15,20,39,55,50])->update(['visible' => 1]); Gateway::all()->each(function ($gateway) { $gateway->site_url = $gateway->getHelp(); From e0f83af6535ceaac8eeac012ac6ac274bc7d0829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Tue, 4 May 2021 14:56:30 +0200 Subject: [PATCH 18/26] Corret class instantiate --- app/Models/Gateway.php | 6 ++++++ app/PaymentDrivers/BraintreePaymentDriver.php | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/Models/Gateway.php b/app/Models/Gateway.php index 5d470680aab5..3db380c32266 100644 --- a/app/Models/Gateway.php +++ b/app/Models/Gateway.php @@ -94,6 +94,12 @@ class Gateway extends StaticModel case 39: return [GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true]]; //Checkout break; + case 50: + return [ + GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true], + GatewayType::PAYPAL => ['refund' => true, 'token_billing' => true] + ]; + break; default: return []; break; diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index 075eee3251ad..86b0bc8d78ba 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -41,7 +41,7 @@ class BraintreePaymentDriver extends BaseDriver public static $methods = [ GatewayType::CREDIT_CARD => CreditCard::class, - GatewayType::PAYPAL, + GatewayType::PAYPAL => PayPal::class, ]; const SYSTEM_LOG_TYPE = SystemLog::TYPE_BRAINTREE; From 6c80243987fe105f0ca11f57377dc06db1ef4527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Tue, 4 May 2021 17:01:47 +0200 Subject: [PATCH 19/26] "New account" translation --- resources/lang/en/texts.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index bc3acc44f4f5..5f3b1819e5cd 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -4237,6 +4237,7 @@ $LANG = array( 'notification_credit_created_subject' => 'Credit :invoice was created for :client', 'payment_method_cannot_be_authorized_first' => 'This payment method can be can saved for future use, once you complete your first transaction. Don\'t forget to check "Store credit card details" during payment process.', + 'new_account' => 'New account', ); return $LANG; From 54a12235b4472070d87c61068b00d3937fd18dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Tue, 4 May 2021 17:02:14 +0200 Subject: [PATCH 20/26] PayPal integration --- app/PaymentDrivers/Braintree/PayPal.php | 147 ++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/app/PaymentDrivers/Braintree/PayPal.php b/app/PaymentDrivers/Braintree/PayPal.php index 747ce562cc99..beb7224b6048 100644 --- a/app/PaymentDrivers/Braintree/PayPal.php +++ b/app/PaymentDrivers/Braintree/PayPal.php @@ -4,6 +4,14 @@ namespace App\PaymentDrivers\Braintree; +use App\Exceptions\PaymentFailed; +use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest; +use App\Jobs\Mail\PaymentFailureMailer; +use App\Jobs\Util\SystemLogger; +use App\Models\GatewayType; +use App\Models\Payment; +use App\Models\PaymentType; +use App\Models\SystemLog; use App\PaymentDrivers\BraintreePaymentDriver; class PayPal @@ -33,4 +41,143 @@ class PayPal return render('gateways.braintree.paypal.pay', $data); } + + public function paymentResponse(PaymentResponseRequest $request) + { + $state = [ + 'server_response' => json_decode($request->gateway_response), + 'payment_hash' => $request->payment_hash, + ]; + + $state = array_merge($state, $request->all()); + $state['store_card'] = boolval($state['store_card']); + + $this->braintree->payment_hash->data = array_merge((array)$this->braintree->payment_hash->data, $state); + $this->braintree->payment_hash->save(); + + $customer = $this->braintree->findOrCreateCustomer(); + + $token = $this->getPaymentToken($request->all(), $customer->id); + + $result = $this->braintree->gateway->transaction()->sale([ + 'amount' => $this->braintree->payment_hash->data->amount_with_fee, + 'paymentMethodToken' => $token, + 'deviceData' => $state['client-data'], + 'options' => [ + 'submitForSettlement' => True, + 'paypal' => [ + 'description' => 'Meaningful description.', + ] + ], + ]); + + if ($result->success) { + $this->braintree->logSuccessfulGatewayResponse( + ['response' => $request->server_response, 'data' => $this->braintree->payment_hash], + SystemLog::TYPE_BRAINTREE + ); + + if ($request->store_card && is_null($request->token)) { + $payment_method = $this->braintree->gateway->paymentMethod()->find($token); + + $this->storePaymentMethod($payment_method, $customer->id); + } + + return $this->processSuccessfulPayment($result); + } + + return $this->processUnsuccessfulPayment($result); + } + + private function getPaymentToken(array $data, string $customerId) + { + if (array_key_exists('token', $data) && !is_null($data['token'])) { + return $data['token']; + } + + $gateway_response = json_decode($data['gateway_response']); + + $payment_method = $this->braintree->gateway->paymentMethod()->create([ + 'customerId' => $customerId, + 'paymentMethodNonce' => $gateway_response->nonce, + ]); + + return $payment_method->paymentMethod->token; + } + + /** + * Process & complete the successful PayPal transaction. + * + * @param $response + * @return \Illuminate\Http\RedirectResponse + */ + private function processSuccessfulPayment($response): \Illuminate\Http\RedirectResponse + { + $state = $this->braintree->payment_hash->data; + + $data = [ + 'payment_type' => PaymentType::PAYPAL, + 'amount' => $this->braintree->payment_hash->data->amount_with_fee, + 'transaction_reference' => $response->transaction->id, + 'gateway_type_id' => GatewayType::PAYPAL, + ]; + + $payment = $this->braintree->createPayment($data, Payment::STATUS_COMPLETED); + + SystemLogger::dispatch( + ['response' => $response, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_BRAINTREE, + $this->braintree->client + ); + + return redirect()->route('client.payments.show', ['payment' => $this->braintree->encodePrimaryKey($payment->id)]); + } + + private function processUnsuccessfulPayment($response) + { + PaymentFailureMailer::dispatch($this->braintree->client, $response->message, $this->braintree->client->company, $this->braintree->payment_hash->data->amount_with_fee); + + PaymentFailureMailer::dispatch( + $this->braintree->client, + $response, + $this->braintree->client->company, + $this->braintree->payment_hash->data->amount_with_fee, + ); + + $message = [ + 'server_response' => $response, + 'data' => $this->braintree->payment_hash->data, + ]; + + SystemLogger::dispatch( + $message, + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_BRAINTREE, + $this->braintree->client + ); + + throw new PaymentFailed($response->message, 0); + } + + private function storePaymentMethod($method, string $customer_reference) + { + try { + $payment_meta = new \stdClass; + $payment_meta->email = (string)$method->email; + $payment_meta->type = GatewayType::PAYPAL; + + $data = [ + 'payment_meta' => $payment_meta, + 'token' => $method->token, + 'payment_method_id' => $this->braintree->payment_hash->data->payment_method_id, + ]; + + $this->braintree->storeGatewayToken($data, ['gateway_customer_reference' => $customer_reference]); + } catch (\Exception $e) { + return $this->braintree->processInternallyFailedPayment($this->braintree, $e); + } + } } From 0e905371c5713c8e2404d74a0eb1af3db1be4a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Tue, 4 May 2021 17:22:52 +0200 Subject: [PATCH 21/26] Extract PayPal's frontend into separate Javascript --- .../js/clients/payments/braintree-paypal.js | 113 ++++++++++++++++++ .../gateways/braintree/paypal/pay.blade.php | 91 +++++--------- webpack.mix.js | 3 + 3 files changed, 147 insertions(+), 60 deletions(-) create mode 100644 resources/js/clients/payments/braintree-paypal.js diff --git a/resources/js/clients/payments/braintree-paypal.js b/resources/js/clients/payments/braintree-paypal.js new file mode 100644 index 000000000000..b9a8981402a6 --- /dev/null +++ b/resources/js/clients/payments/braintree-paypal.js @@ -0,0 +1,113 @@ +/** + * 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 + */ + +class BraintreePayPal { + initBraintreeDataCollector() { + window.braintree.client.create({ + authorization: document.querySelector('meta[name=client-token]').content + }, function (err, clientInstance) { + window.braintree.dataCollector.create({ + client: clientInstance, + paypal: true + }, function (err, dataCollectorInstance) { + if (err) { + return; + } + + document.querySelector('input[name=client-data]').value = dataCollectorInstance.deviceData; + }); + }); + } + + static getPaymentDetails() { + return { + flow: 'vault', + } + } + + static handleErrorMessage(message) { + let errorsContainer = document.getElementById('errors'); + + errorsContainer.innerText = message; + errorsContainer.hidden = false; + } + + handlePaymentWithToken() { + Array + .from(document.getElementsByClassName('toggle-payment-with-token')) + .forEach((element) => element.addEventListener('click', (element) => { + document.getElementById('paypal-button').classList.add('hidden'); + document.getElementById('save-card--container').style.display = 'none'; + document.querySelector('input[name=token]').value = element.target.dataset.token; + + document.getElementById('pay-now-with-token').classList.remove('hidden'); + document.getElementById('pay-now').classList.add('hidden'); + })); + + let payNowWithToken = document.getElementById('pay-now-with-token'); + + payNowWithToken + .addEventListener('click', (element) => { + payNowWithToken.disabled = true; + payNowWithToken.querySelector('svg').classList.remove('hidden'); + payNowWithToken.querySelector('span').classList.add('hidden'); + + document.getElementById('server-response').submit(); + }); + } + + handle() { + this.initBraintreeDataCollector(); + this.handlePaymentWithToken(); + + braintree.client.create({ + authorization: document.querySelector('meta[name=client-token]').content, + }).then(function (clientInstance) { + return braintree.paypalCheckout.create({ + client: clientInstance + }); + }).then(function (paypalCheckoutInstance) { + return paypalCheckoutInstance.loadPayPalSDK({ + vault: true + }).then(function (paypalCheckoutInstance) { + return paypal.Buttons({ + fundingSource: paypal.FUNDING.PAYPAL, + + createBillingAgreement: function () { + return paypalCheckoutInstance.createPayment(BraintreePayPal.getPaymentDetails()); + }, + + onApprove: function (data, actions) { + return paypalCheckoutInstance.tokenizePayment(data).then(function (payload) { + document.querySelector('input[name=gateway_response]').value = JSON.stringify(payload); + document.getElementById('server-response').submit(); + }); + }, + + onCancel: function (data) { + // .. + }, + + onError: function (err) { + console.log(err.message); + + BraintreePayPal.handleErrorMessage(err.message); + } + }).render('#paypal-button'); + }); + }).catch(function (err) { + console.log(err.message); + + BraintreePayPal.handleErrorMessage(err.message); + }); + } +} + +new BraintreePayPal().handle(); diff --git a/resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php index 8103930fc3a6..daaf294d1be1 100644 --- a/resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php @@ -5,6 +5,7 @@ + @endsection @section('gateway_content') @@ -29,70 +30,40 @@ @include('portal.ninja2020.gateways.includes.payment_details') + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')]) + @if(count($tokens) > 0) + @foreach($tokens as $token) + + @endforeach + @endisset + + + @endcomponent + + @include('portal.ninja2020.gateways.includes.save_card') + @component('portal.ninja2020.components.general.card-element-single')
@endcomponent + + @include('portal.ninja2020.gateways.includes.pay_now', ['id' => 'pay-now-with-token', 'class' => 'hidden']) @endsection @section('gateway_footer') - + @endsection diff --git a/webpack.mix.js b/webpack.mix.js index f9ad1769f67a..f0a1fcf9ed15 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -69,6 +69,9 @@ mix.js("resources/js/app.js", "public/js") .js( "resources/js/clients/payments/braintree-credit-card.js", "public/js/clients/payments/braintree-credit-card.js" + ).js( + "resources/js/clients/payments/braintree-paypal.js", + "public/js/clients/payments/braintree-paypal.js" ); mix.copyDirectory('node_modules/card-js/card-js.min.css', 'public/css/card-js.min.css'); From 78f87a8da04eb42b25e38c096298b2f090ef7ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Tue, 4 May 2021 17:25:30 +0200 Subject: [PATCH 22/26] PayPal authorize page message --- app/PaymentDrivers/Braintree/PayPal.php | 12 ++++++++++++ .../gateways/braintree/paypal/authorize.blade.php | 7 +++++++ 2 files changed, 19 insertions(+) create mode 100644 resources/views/portal/ninja2020/gateways/braintree/paypal/authorize.blade.php diff --git a/app/PaymentDrivers/Braintree/PayPal.php b/app/PaymentDrivers/Braintree/PayPal.php index beb7224b6048..ebba89ea5be8 100644 --- a/app/PaymentDrivers/Braintree/PayPal.php +++ b/app/PaymentDrivers/Braintree/PayPal.php @@ -28,6 +28,18 @@ class PayPal $this->braintree->init(); } + public function authorizeView(array $data) + { + $data['gateway'] = $this->braintree; + + return render('gateways.braintree.paypal.authorize', $data); + } + + public function authorizeResponse($data): \Illuminate\Http\RedirectResponse + { + return back(); + } + /** * Credit card payment page. * diff --git a/resources/views/portal/ninja2020/gateways/braintree/paypal/authorize.blade.php b/resources/views/portal/ninja2020/gateways/braintree/paypal/authorize.blade.php new file mode 100644 index 000000000000..a130947b497e --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/braintree/paypal/authorize.blade.php @@ -0,0 +1,7 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.paypal'), 'card_title' => ctrans('texts.paypal')]) + +@section('gateway_content') + @component('portal.ninja2020.components.general.card-element-single', ['title' => ctrans('texts.paypal'), 'show_title' => false]) + {{ __('texts.payment_method_cannot_be_authorized_first') }} + @endcomponent +@endsection From 28363013ee8781f6edbb979b7aa5260a1f124c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Wed, 5 May 2021 16:59:50 +0200 Subject: [PATCH 23/26] PayPal: Storing payment method checkbox --- public/js/clients/payments/braintree-paypal.js | 2 ++ .../js/clients/payments/braintree-paypal.js.LICENSE.txt | 9 +++++++++ public/mix-manifest.json | 1 + resources/js/clients/payments/braintree-paypal.js | 9 +++++++++ 4 files changed, 21 insertions(+) create mode 100644 public/js/clients/payments/braintree-paypal.js create mode 100644 public/js/clients/payments/braintree-paypal.js.LICENSE.txt diff --git a/public/js/clients/payments/braintree-paypal.js b/public/js/clients/payments/braintree-paypal.js new file mode 100644 index 000000000000..231191fa7562 --- /dev/null +++ b/public/js/clients/payments/braintree-paypal.js @@ -0,0 +1,2 @@ +/*! For license information please see braintree-paypal.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=18)}({18:function(e,t,n){e.exports=n("cZZG")},cZZG:function(e,t){function n(e,t){for(var n=0;n Date: Wed, 5 May 2021 17:24:31 +0200 Subject: [PATCH 24/26] Refunds wip --- app/PaymentDrivers/BraintreePaymentDriver.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index 86b0bc8d78ba..10b3449879e4 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -19,6 +19,7 @@ use App\Jobs\Util\SystemLogger; use App\Models\ClientGatewayToken; use App\Models\GatewayType; use App\Models\Invoice; +use App\Models\Payment; use App\Models\PaymentHash; use App\Models\PaymentType; use App\Models\SystemLog; @@ -115,6 +116,17 @@ class BraintreePaymentDriver extends BaseDriver } } + public function refund(Payment $payment, $amount, $return_client_response = false) + { + $this->init(); + + try { + $response = $this->gateway->transaction()->refund($payment->transaction_reference, $amount); + } catch(\Exception $e) { + // .. + } + } + public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash) { $amount = array_sum(array_column($payment_hash->invoices(), 'amount')) + $payment_hash->fee_total; From 92037546dad0b45be64fa59dca7e41de889add04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Thu, 6 May 2021 16:38:54 +0200 Subject: [PATCH 25/26] Refunds --- app/PaymentDrivers/BraintreePaymentDriver.php | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php index 10b3449879e4..fd911f4360f1 100644 --- a/app/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -122,8 +122,22 @@ class BraintreePaymentDriver extends BaseDriver try { $response = $this->gateway->transaction()->refund($payment->transaction_reference, $amount); - } catch(\Exception $e) { - // .. + + return [ + 'transaction_reference' => $response->id, + 'transaction_response' => json_encode($response), + 'success' => (bool) $response->success, + 'description' => $response->status, + 'code' => 0, + ]; + } catch (\Exception $e) { + return [ + 'transaction_reference' => null, + 'transaction_response' => json_encode($e->getMessage()), + 'success' => false, + 'description' => $e->getMessage(), + 'code' => $e->getCode(), + ]; } } @@ -173,7 +187,7 @@ class BraintreePaymentDriver extends BaseDriver return $payment; } - if (! $result->success) { + if (!$result->success) { $this->unWindGatewayFees($payment_hash); PaymentFailureMailer::dispatch($this->client, $result->transaction->additionalProcessorResponse, $this->client->company, $this->payment_hash->data->amount_with_fee); From f92600e7416817ee6f13b68e70c74980852f5b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Beganovi=C4=87?= Date: Mon, 10 May 2021 11:55:31 +0200 Subject: [PATCH 26/26] Sync composer.lock --- composer.lock | 103 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 75 insertions(+), 28 deletions(-) diff --git a/composer.lock b/composer.lock index 89191270e1a2..b139c2f11369 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": "38a79899673526624db4d62a76dd9a5e", + "content-hash": "cbd0c778d0092866b6c2d3f693f0e5fe", "packages": [ { "name": "asm/php-ansible", @@ -12,12 +12,12 @@ "source": { "type": "git", "url": "https://github.com/maschmann/php-ansible.git", - "reference": "4f2145cad264fd9f800baf6d3a79dd43fd8009db" + "reference": "d526011521ea8f3433d8e940d2a1839474b1c1f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maschmann/php-ansible/zipball/4f2145cad264fd9f800baf6d3a79dd43fd8009db", - "reference": "4f2145cad264fd9f800baf6d3a79dd43fd8009db", + "url": "https://api.github.com/repos/maschmann/php-ansible/zipball/d526011521ea8f3433d8e940d2a1839474b1c1f4", + "reference": "d526011521ea8f3433d8e940d2a1839474b1c1f4", "shasum": "" }, "require": { @@ -54,9 +54,9 @@ ], "support": { "issues": "https://github.com/maschmann/php-ansible/issues", - "source": "https://github.com/maschmann/php-ansible/tree/master" + "source": "https://github.com/maschmann/php-ansible/tree/v2.2" }, - "time": "2021-03-02T18:27:29+00:00" + "time": "2021-05-09T14:23:09+00:00" }, { "name": "authorizenet/authorizenet", @@ -297,6 +297,55 @@ }, "time": "2021-03-19T21:20:07+00:00" }, + { + "name": "braintree/braintree_php", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/braintree/braintree_php.git", + "reference": "2406535506ebdbfd685596d890746a4a2db6fa9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/braintree/braintree_php/zipball/2406535506ebdbfd685596d890746a4a2db6fa9e", + "reference": "2406535506ebdbfd685596d890746a4a2db6fa9e", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-dom": "*", + "ext-hash": "*", + "ext-openssl": "*", + "ext-xmlwriter": "*", + "php": ">=7.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Braintree\\": "lib/Braintree" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Braintree", + "homepage": "https://www.braintreepayments.com" + } + ], + "description": "Braintree PHP Client Library", + "support": { + "issues": "https://github.com/braintree/braintree_php/issues", + "source": "https://github.com/braintree/braintree_php/tree/6.1.0" + }, + "time": "2021-05-06T20:43:19+00:00" + }, { "name": "brick/math", "version": "0.9.2", @@ -2106,16 +2155,16 @@ }, { "name": "google/apiclient-services", - "version": "v0.173.0", + "version": "v0.174.0", "source": { "type": "git", "url": "https://github.com/googleapis/google-api-php-client-services.git", - "reference": "9034b5ba3e25c9ad8e49b6457b9cad21fd9d9847" + "reference": "004c5280f5a26a8acbb6f6af6a792e4872b7648a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/9034b5ba3e25c9ad8e49b6457b9cad21fd9d9847", - "reference": "9034b5ba3e25c9ad8e49b6457b9cad21fd9d9847", + "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/004c5280f5a26a8acbb6f6af6a792e4872b7648a", + "reference": "004c5280f5a26a8acbb6f6af6a792e4872b7648a", "shasum": "" }, "require": { @@ -2141,9 +2190,9 @@ ], "support": { "issues": "https://github.com/googleapis/google-api-php-client-services/issues", - "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.173.0" + "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.174.0" }, - "time": "2021-05-02T11:20:02+00:00" + "time": "2021-05-08T11:20:03+00:00" }, { "name": "google/auth", @@ -3884,28 +3933,27 @@ }, { "name": "league/omnipay", - "version": "dev-master", + "version": "v3.1.0", "source": { "type": "git", "url": "https://github.com/thephpleague/omnipay.git", - "reference": "d090c000030fc759e32f6f747873b5d630103030" + "reference": "1ba7c8a3312cf2342458b99c9e5b86eaae44aed2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/omnipay/zipball/d090c000030fc759e32f6f747873b5d630103030", - "reference": "d090c000030fc759e32f6f747873b5d630103030", + "url": "https://api.github.com/repos/thephpleague/omnipay/zipball/1ba7c8a3312cf2342458b99c9e5b86eaae44aed2", + "reference": "1ba7c8a3312cf2342458b99c9e5b86eaae44aed2", "shasum": "" }, "require": { "omnipay/common": "^3", - "php": "^7.2|^8.0", + "php": "^7.2", "php-http/discovery": "^1.12", "php-http/guzzle7-adapter": "^0.1" }, "require-dev": { "omnipay/tests": "^3" }, - "default-branch": true, "type": "metapackage", "extra": { "branch-alias": { @@ -3936,9 +3984,9 @@ ], "support": { "issues": "https://github.com/thephpleague/omnipay/issues", - "source": "https://github.com/thephpleague/omnipay/tree/master" + "source": "https://github.com/thephpleague/omnipay/tree/v3.1.0" }, - "time": "2021-05-02T15:02:18+00:00" + "time": "2020-09-22T14:02:17+00:00" }, { "name": "livewire/livewire", @@ -4689,21 +4737,21 @@ }, { "name": "omnipay/common", - "version": "dev-master", + "version": "v3.0.5", "source": { "type": "git", "url": "https://github.com/thephpleague/omnipay-common.git", - "reference": "e1ebc22615f14219d31cefdf62d7036feb228b1c" + "reference": "0d1f4486c1c873537ac030d37c7ce2986c4de1d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/omnipay-common/zipball/e1ebc22615f14219d31cefdf62d7036feb228b1c", - "reference": "e1ebc22615f14219d31cefdf62d7036feb228b1c", + "url": "https://api.github.com/repos/thephpleague/omnipay-common/zipball/0d1f4486c1c873537ac030d37c7ce2986c4de1d2", + "reference": "0d1f4486c1c873537ac030d37c7ce2986c4de1d2", "shasum": "" }, "require": { "moneyphp/money": "^3.1", - "php": "^5.6|^7|^8", + "php": "^5.6|^7", "php-http/client-implementation": "^1", "php-http/discovery": "^1.2.1", "php-http/message": "^1.5", @@ -4718,7 +4766,6 @@ "suggest": { "league/omnipay": "The default Omnipay package provides a default HTTP Adapter." }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { @@ -4770,9 +4817,9 @@ ], "support": { "issues": "https://github.com/thephpleague/omnipay-common/issues", - "source": "https://github.com/thephpleague/omnipay-common/tree/master" + "source": "https://github.com/thephpleague/omnipay-common/tree/v3.0.5" }, - "time": "2020-12-13T12:53:48+00:00" + "time": "2020-08-20T18:22:12+00:00" }, { "name": "omnipay/paypal",