diff --git a/app/Http/Controllers/ClientPortal/PaymentController.php b/app/Http/Controllers/ClientPortal/PaymentController.php index 8531c36fefb1..e768163aca00 100644 --- a/app/Http/Controllers/ClientPortal/PaymentController.php +++ b/app/Http/Controllers/ClientPortal/PaymentController.php @@ -243,7 +243,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')); @@ -303,24 +303,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/Models/Gateway.php b/app/Models/Gateway.php index c182f4e1d7ad..d7877116df24 100644 --- a/app/Models/Gateway.php +++ b/app/Models/Gateway.php @@ -95,6 +95,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/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..0006ea42cbd8 --- /dev/null +++ b/app/PaymentDrivers/Braintree/CreditCard.php @@ -0,0 +1,209 @@ +braintree = $braintree; + + $this->braintree->init(); + } + + public function authorizeView(array $data) + { + $data['gateway'] = $this->braintree; + + return render('gateways.braintree.credit_card.authorize', $data); + } + + public function authorizeResponse($data): \Illuminate\Http\RedirectResponse + { + return back(); + } + + /** + * 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.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 = [ + '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 + ], + ]); + + 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, $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($response->transaction->creditCard['cardType'])), + 'amount' => $this->braintree->payment_hash->data->amount_with_fee, + 'transaction_reference' => $response->transaction->id, + 'gateway_type_id' => GatewayType::CREDIT_CARD, + ]; + + $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, $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->transaction->additionalProcessorResponse, $response->transaction->processorResponseCode); + } + + private function storePaymentMethod($method, $customer_reference) + { + 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->last4; + $payment_meta->type = GatewayType::CREDIT_CARD; + + $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); + } + } +} diff --git a/app/PaymentDrivers/Braintree/PayPal.php b/app/PaymentDrivers/Braintree/PayPal.php new file mode 100644 index 000000000000..ebba89ea5be8 --- /dev/null +++ b/app/PaymentDrivers/Braintree/PayPal.php @@ -0,0 +1,195 @@ +braintree = $braintree; + + $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. + * + * @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); + } + + 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); + } + } +} diff --git a/app/PaymentDrivers/BraintreePaymentDriver.php b/app/PaymentDrivers/BraintreePaymentDriver.php new file mode 100644 index 000000000000..fd911f4360f1 --- /dev/null +++ b/app/PaymentDrivers/BraintreePaymentDriver.php @@ -0,0 +1,211 @@ + CreditCard::class, + GatewayType::PAYPAL => PayPal::class, + ]; + + const SYSTEM_LOG_TYPE = SystemLog::TYPE_BRAINTREE; + + 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) + { + $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 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); + } + + public function processPaymentResponse($request) + { + 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; + } + } + + public function refund(Payment $payment, $amount, $return_client_response = false) + { + $this->init(); + + try { + $response = $this->gateway->transaction()->refund($payment->transaction_reference, $amount); + + 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(), + ]; + } + } + + 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; + } + } +} diff --git a/composer.json b/composer.json index 569fda98006f..18ecfbf729b4 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,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 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", 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/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/cypress/integration/gateways/braintree_credit_card.spec.js b/cypress/integration/gateways/braintree_credit_card.spec.js new file mode 100644 index 000000000000..967041045034 --- /dev/null +++ b/cypress/integration/gateways/braintree_credit_card.spec.js @@ -0,0 +1,75 @@ +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'); + }); + + 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.'); + }); +}); 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..f027712df581 --- /dev/null +++ b/database/migrations/2021_05_03_152940_make_braintree_provider_visible.php @@ -0,0 +1,29 @@ +update(['visible' => 1]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // + } +} diff --git a/database/seeders/PaymentLibrariesSeeder.php b/database/seeders/PaymentLibrariesSeeder.php index 8e2b4f2df034..73294531ee59 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(); 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/js/clients/payments/braintree-paypal.js b/resources/js/clients/payments/braintree-paypal.js new file mode 100644 index 000000000000..ff096640c2e6 --- /dev/null +++ b/resources/js/clients/payments/braintree-paypal.js @@ -0,0 +1,122 @@ +/** + * 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) { + let tokenBillingCheckbox = document.querySelector( + 'input[name="token-billing-checkbox"]:checked' + ); + + if (tokenBillingCheckbox) { + document.querySelector('input[name="store_card"]').value = + tokenBillingCheckbox.value; + } + + 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/lang/en/texts.php b/resources/lang/en/texts.php index 909be7c6fd31..a10ad715f272 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -4239,7 +4239,8 @@ $LANG = array( 'max_companies_desc' => 'You have reached your maximum number of companies. Delete existing companies to migrate new ones.', 'migration_already_completed' => 'Company already migrated', 'migration_already_completed_desc' => 'Looks like you already migrated :company_name to the V5 version of the Invoice Ninja. In case you want to start over, you can force migrate to wipe existing data.', - + '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', 'activity_100' => ':user created recurring invoice :recurring_invoice', 'activity_101' => ':user updated recurring invoice :recurring_invoice', 'activity_102' => ':user archived recurring invoice :recurring_invoice', 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 e6d820c4ac99..5d96816ccf11 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)
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 }}
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/braintree/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php new file mode 100644 index 000000000000..ea775a3016b3 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/braintree/credit_card/pay.blade.php @@ -0,0 +1,74 @@ +@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 + + @include('portal.ninja2020.gateways.includes.save_card') + + @component('portal.ninja2020.components.general.card-element-single') +
+ @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 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 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..daaf294d1be1 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/braintree/paypal/pay.blade.php @@ -0,0 +1,69 @@ +@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', ['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/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 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 diff --git a/webpack.mix.js b/webpack.mix.js index ef423fb2c701..f0a1fcf9ed15 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -65,6 +65,13 @@ 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" + ).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');