1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-09-24 10:21:35 +02:00

Merge pull request #6478 from beganovich/v5-632

(Work in progress) Square
This commit is contained in:
David Bomba 2021-08-21 21:13:44 +10:00 committed by GitHub
commit dbd84bc847
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 558 additions and 341 deletions

View File

@ -56,4 +56,9 @@ class PaymentResponseRequest extends FormRequest
]); ]);
} }
} }
public function shouldUseToken(): bool
{
return (bool) $this->token;
}
} }

View File

@ -12,20 +12,15 @@
namespace App\PaymentDrivers\Square; namespace App\PaymentDrivers\Square;
use App\Exceptions\PaymentFailed; use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
use App\Jobs\Mail\PaymentFailureMailer;
use App\Jobs\Util\SystemLogger;
use App\Models\ClientGatewayToken; use App\Models\ClientGatewayToken;
use App\Models\GatewayType; use App\Models\GatewayType;
use App\Models\Payment; use App\Models\Payment;
use App\Models\PaymentHash;
use App\Models\PaymentType; use App\Models\PaymentType;
use App\Models\SystemLog;
use App\PaymentDrivers\SquarePaymentDriver; use App\PaymentDrivers\SquarePaymentDriver;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Square\Http\ApiResponse;
class CreditCard class CreditCard
{ {
@ -41,11 +36,9 @@ class CreditCard
public function authorizeView($data) public function authorizeView($data)
{ {
$data['gateway'] = $this->square_driver; $data['gateway'] = $this->square_driver;
return render('gateways.square.credit_card.authorize', $data); return render('gateways.square.credit_card.authorize', $data);
} }
public function authorizeResponse($request) public function authorizeResponse($request)
@ -121,63 +114,131 @@ class CreditCard
$cgt['payment_meta'] = $payment_meta; $cgt['payment_meta'] = $payment_meta;
$token = $this->square_driver->storeGatewayToken($cgt, []); $token = $this->square_driver->storeGatewayToken($cgt, [
'gateway_customer_reference' => $this->findOrCreateClient(),
]);
return redirect()->route('client.payment_methods.index'); return redirect()->route('client.payment_methods.index');
} }
public function paymentView($data) public function paymentView($data)
{ {
$data['gateway'] = $this->square_driver; $data['gateway'] = $this->square_driver;
return render('gateways.square.credit_card.pay', $data); return render('gateways.square.credit_card.pay', $data);
} }
public function processPaymentResponse($request) public function paymentResponse(PaymentResponseRequest $request)
{ {
$token = $request->sourceId;
$amount = $this->square_driver->convertAmount(
$this->square_driver->payment_hash->data->amount_with_fee
);
if ($request->shouldUseToken()) {
$cgt = ClientGatewayToken::where('token', $request->token)->first();
$token = $cgt->token;
}
$amount_money = new \Square\Models\Money();
$amount_money->setAmount($amount);
$amount_money->setCurrency($this->square_driver->client->currency()->code);
$body = new \Square\Models\CreatePaymentRequest($token, Str::random(32), $amount_money);
$body->setAutocomplete(true);
$body->setLocationId($this->square_driver->company_gateway->getConfigField('locationId'));
$body->setReferenceId(Str::random(16));
if ($request->shouldUseToken()) {
$body->setCustomerId($cgt->gateway_customer_reference);
}
/** @var ApiResponse */
$response = $this->square_driver->square->getPaymentsApi()->createPayment($body);
if ($response->isSuccess()) {
if ($request->shouldStoreToken()) {
$this->storePaymentMethod($response);
}
return $this->processSuccessfulPayment($response);
}
return $this->processUnsuccessfulPayment($response);
} }
/* This method is stubbed ready to go - you just need to harvest the equivalent 'transaction_reference' */ private function storePaymentMethod(ApiResponse $response)
private function processSuccessfulPayment($response)
{ {
$payment = \json_decode($response->getBody());
$card = new \Square\Models\Card();
$card->setCardholderName($this->square_driver->client->present()->name());
$card->setCustomerId($this->findOrCreateClient());
$card->setReferenceId(Str::random(8));
$body = new \Square\Models\CreateCardRequest(Str::random(32), $payment->payment->id, $card);
/** @var ApiResponse */
$api_response = $this->square_driver
->square
->getCardsApi()
->createCard($body);
if (!$api_response->isSuccess()) {
return $this->processUnsuccessfulPayment($api_response);
}
$card = \json_decode($api_response->getBody());
$cgt = [];
$cgt['token'] = $card->card->id;
$cgt['payment_method_id'] = GatewayType::CREDIT_CARD;
$payment_meta = new \stdClass;
$payment_meta->exp_month = $card->card->exp_month;
$payment_meta->exp_year = $card->card->exp_year;
$payment_meta->brand = $card->card->card_brand;
$payment_meta->last4 = $card->card->last_4;
$payment_meta->type = GatewayType::CREDIT_CARD;
$cgt['payment_meta'] = $payment_meta;
$this->square_driver->storeGatewayToken($cgt, [
'gateway_customer_reference' => $this->findOrCreateClient(),
]);
}
private function processSuccessfulPayment(ApiResponse $response)
{
$body = json_decode($response->getBody());
$amount = array_sum(array_column($this->square_driver->payment_hash->invoices(), 'amount')) + $this->square_driver->payment_hash->fee_total; $amount = array_sum(array_column($this->square_driver->payment_hash->invoices(), 'amount')) + $this->square_driver->payment_hash->fee_total;
$payment_record = []; $payment_record = [];
$payment_record['amount'] = $amount; $payment_record['amount'] = $amount;
$payment_record['payment_type'] = PaymentType::CREDIT_CARD_OTHER; $payment_record['payment_type'] = PaymentType::CREDIT_CARD_OTHER;
$payment_record['gateway_type_id'] = GatewayType::CREDIT_CARD; $payment_record['gateway_type_id'] = GatewayType::CREDIT_CARD;
// $payment_record['transaction_reference'] = $response->transaction_id; $payment_record['transaction_reference'] = $body->payment->id;
$payment = $this->square_driver->createPayment($payment_record, Payment::STATUS_COMPLETED); $payment = $this->square_driver->createPayment($payment_record, Payment::STATUS_COMPLETED);
return redirect()->route('client.payments.show', ['payment' => $this->encodePrimaryKey($payment->id)]); return redirect()->route('client.payments.show', ['payment' => $this->encodePrimaryKey($payment->id)]);
} }
private function processUnsuccessfulPayment($response) private function processUnsuccessfulPayment(ApiResponse $response)
{ {
// array ( $body = \json_decode($response->getBody());
// 0 =>
// Square\Models\Error::__set_state(array(
// 'category' => 'INVALID_REQUEST_ERROR',
// 'code' => 'INVALID_CARD_DATA',
// 'detail' => 'Invalid card data.',
// 'field' => 'source_id',
// )),
// )
$data = [ $data = [
'response' => $response, 'response' => $response,
'error' => $response[0]['detail'], 'error' => $body->errors[0]->detail,
'error_code' => '', 'error_code' => '',
]; ];
return $this->square_driver->processUnsuccessfulTransaction($data); return $this->square_driver->processUnsuccessfulTransaction($data);
} }
@ -186,7 +247,6 @@ class CreditCard
private function findOrCreateClient() private function findOrCreateClient()
{ {
$email_address = new \Square\Models\CustomerTextFilter(); $email_address = new \Square\Models\CustomerTextFilter();
$email_address->setExact($this->square_driver->client->present()->email()); $email_address->setExact($this->square_driver->client->present()->email());
@ -214,8 +274,9 @@ class CreditCard
$errors = $api_response->getErrors(); $errors = $api_response->getErrors();
} }
if($customers) if ($customers) {
return $customers->customers[0]->id; return $customers->customers[0]->id;
}
return $this->createClient(); return $this->createClient();
} }
@ -250,11 +311,9 @@ class CreditCard
if ($api_response->isSuccess()) { if ($api_response->isSuccess()) {
$result = $api_response->getResult(); $result = $api_response->getResult();
return $result->getCustomer()->getId(); return $result->getCustomer()->getId();
} else { } else {
$errors = $api_response->getErrors(); $errors = $api_response->getErrors();
return $this->processUnsuccessfulPayment($errors); return $this->processUnsuccessfulPayment($errors);
} }
} }
} }

View File

@ -12,13 +12,18 @@
namespace App\PaymentDrivers; namespace App\PaymentDrivers;
use App\Http\Requests\Payments\PaymentWebhookRequest; use App\Http\Requests\Payments\PaymentWebhookRequest;
use App\Jobs\Mail\PaymentFailureMailer;
use App\Jobs\Util\SystemLogger;
use App\Models\ClientGatewayToken; use App\Models\ClientGatewayToken;
use App\Models\GatewayType; use App\Models\GatewayType;
use App\Models\Invoice;
use App\Models\Payment; use App\Models\Payment;
use App\Models\PaymentHash; use App\Models\PaymentHash;
use App\Models\PaymentType;
use App\Models\SystemLog; use App\Models\SystemLog;
use App\PaymentDrivers\Square\CreditCard; use App\PaymentDrivers\Square\CreditCard;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Square\Http\ApiResponse;
class SquarePaymentDriver extends BaseDriver class SquarePaymentDriver extends BaseDriver
{ {
@ -42,7 +47,6 @@ class SquarePaymentDriver extends BaseDriver
public function init() public function init()
{ {
$this->square = new \Square\SquareClient([ $this->square = new \Square\SquareClient([
'accessToken' => $this->company_gateway->getConfigField('accessToken'), 'accessToken' => $this->company_gateway->getConfigField('accessToken'),
'environment' => $this->company_gateway->getConfigField('testMode') ? \Square\Environment::SANDBOX : \Square\Environment::PRODUCTION, 'environment' => $this->company_gateway->getConfigField('testMode') ? \Square\Environment::SANDBOX : \Square\Environment::PRODUCTION,
@ -56,7 +60,7 @@ class SquarePaymentDriver extends BaseDriver
{ {
$types = []; $types = [];
$types[] = GatewayType::CREDIT_CARD; $types[] = GatewayType::CREDIT_CARD;
return $types; return $types;
} }
@ -91,12 +95,100 @@ class SquarePaymentDriver extends BaseDriver
public function refund(Payment $payment, $amount, $return_client_response = false) public function refund(Payment $payment, $amount, $return_client_response = false)
{ {
//this is your custom implementation from here $this->init();
$amount_money = new \Square\Models\Money();
$amount_money->setAmount($this->convertAmount($amount));
$amount_money->setCurrency($this->square_driver->client->currency()->code);
$body = new \Square\Models\RefundPaymentRequest(\Illuminate\Support\Str::random(32), $amount_money, $payment->transaction_reference);
/** @var ApiResponse */
$response = $this->square->getRefundsApi()->refund($body);
// if ($response->isSuccess()) {
// return [
// 'transaction_reference' => $refund->action_id,
// 'transaction_response' => json_encode($response),
// 'success' => $checkout_payment->status == 'Refunded',
// 'description' => $checkout_payment->status,
// 'code' => $checkout_payment->http_code,
// ];
// }
} }
public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash) public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash)
{ {
//this is your custom implementation from here $this->init();
$amount = array_sum(array_column($payment_hash->invoices(), 'amount')) + $payment_hash->fee_total;
$amount = $this->convertAmount($amount);
$invoice = Invoice::whereIn('id', $this->transformKeys(array_column($payment_hash->invoices(), 'invoice_id')))->withTrashed()->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()}";
}
$amount_money = new \Square\Models\Money();
$amount_money->setAmount($amount);
$amount_money->setCurrency($this->client->currency()->code);
$body = new \Square\Models\CreatePaymentRequest($cgt->token, \Illuminate\Support\Str::random(32), $amount_money);
/** @var ApiResponse */
$response = $this->square->getPaymentsApi()->createPayment($body);
$body = json_decode($response->getBody());
if ($response->isSuccess()) {
$amount = array_sum(array_column($this->payment_hash->invoices(), 'amount')) + $this->payment_hash->fee_total;
$payment_record = [];
$payment_record['amount'] = $amount;
$payment_record['payment_type'] = PaymentType::CREDIT_CARD_OTHER;
$payment_record['gateway_type_id'] = GatewayType::CREDIT_CARD;
$payment_record['transaction_reference'] = $body->payment->id;
$payment = $this->createPayment($payment_record, Payment::STATUS_COMPLETED);
SystemLogger::dispatch(
['response' => $response, 'data' => $payment_record],
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_GATEWAY_SUCCESS,
SystemLog::TYPE_CHECKOUT,
$this->client,
$this->client->company,
);
return $payment;
}
$this->unWindGatewayFees($payment_hash);
PaymentFailureMailer::dispatch(
$this->client,
$body->errors[0]->detail,
$this->client->company,
$amount
);
$message = [
'server_response' => $response,
'data' => $payment_hash->data,
];
SystemLogger::dispatch(
$message,
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_GATEWAY_FAILURE,
SystemLog::TYPE_SQUARE,
$this->client,
$this->client->company,
);
return false;
} }
public function processWebhookRequest(PaymentWebhookRequest $request, Payment $payment = null) public function processWebhookRequest(PaymentWebhookRequest $request, Payment $payment = null)
@ -147,4 +239,23 @@ class SquarePaymentDriver extends BaseDriver
return $fields; return $fields;
} }
public function convertAmount($amount): bool
{
$precision = $this->client->currency()->precision;
if ($precision == 0) {
return $amount;
}
if ($precision == 1) {
return $amount*10;
}
if ($precision == 2) {
return $amount*100;
}
return $amount;
}
} }

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/

View File

@ -13,6 +13,7 @@
"/js/clients/payments/checkout-credit-card.js": "/js/clients/payments/checkout-credit-card.js?id=065e5450233cc5b47020", "/js/clients/payments/checkout-credit-card.js": "/js/clients/payments/checkout-credit-card.js?id=065e5450233cc5b47020",
"/js/clients/payments/mollie-credit-card.js": "/js/clients/payments/mollie-credit-card.js?id=73b66e88e2daabcd6549", "/js/clients/payments/mollie-credit-card.js": "/js/clients/payments/mollie-credit-card.js?id=73b66e88e2daabcd6549",
"/js/clients/payments/paytrace-credit-card.js": "/js/clients/payments/paytrace-credit-card.js?id=c8d3808a4c02d1392e96", "/js/clients/payments/paytrace-credit-card.js": "/js/clients/payments/paytrace-credit-card.js?id=c8d3808a4c02d1392e96",
"/js/clients/payments/square-credit-card.js": "/js/clients/payments/square-credit-card.js?id=352f9df0c035939a0bbc",
"/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=81c2623fc1e5769b51c7", "/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=81c2623fc1e5769b51c7",
"/js/clients/payments/stripe-alipay.js": "/js/clients/payments/stripe-alipay.js?id=665ddf663500767f1a17", "/js/clients/payments/stripe-alipay.js": "/js/clients/payments/stripe-alipay.js?id=665ddf663500767f1a17",
"/js/clients/payments/stripe-credit-card.js": "/js/clients/payments/stripe-credit-card.js?id=a30464874dee84678344", "/js/clients/payments/stripe-credit-card.js": "/js/clients/payments/stripe-credit-card.js?id=a30464874dee84678344",

View File

@ -0,0 +1,134 @@
/**
* 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://www.elastic.co/licensing/elastic-license
*/
class SquareCreditCard {
constructor() {
this.appId = document.querySelector('meta[name=square-appId]').content;
this.locationId = document.querySelector(
'meta[name=square-locationId]'
).content;
this.isLoaded = false;
}
async init() {
this.payments = Square.payments(this.appId, this.locationId);
this.card = await this.payments.card();
await this.card.attach('#card-container');
this.isLoaded = true;
let iframeContainer = document.querySelector(
'.sq-card-iframe-container'
);
if (iframeContainer) {
iframeContainer.setAttribute('style', '150px !important');
}
let toggleWithToken = document.querySelector(
'.toggle-payment-with-token'
);
if (toggleWithToken) {
document.getElementById('card-container').classList.add('hidden');
}
}
async completePaymentWithoutToken(e) {
document.getElementById('errors').hidden = true;
e.target.parentElement.disabled = true;
let result = await this.card.tokenize();
if (result.status === 'OK') {
document.getElementById('sourceId').value = result.token;
let tokenBillingCheckbox = document.querySelector(
'input[name="token-billing-checkbox"]:checked'
);
if (tokenBillingCheckbox) {
document.querySelector('input[name="store_card"]').value =
tokenBillingCheckbox.value;
}
return document.getElementById('server_response').submit();
}
document.getElementById('errors').textContent =
result.errors[0].message;
document.getElementById('errors').hidden = false;
e.target.parentElement.disabled = false;
}
async completePaymentUsingToken(e) {
e.target.parentElement.disabled = true;
return document.getElementById('server_response').submit();
}
async handle() {
await this.init();
document
.getElementById('authorize-card')
?.addEventListener('click', (e) =>
this.completePaymentWithoutToken(e)
);
document.getElementById('pay-now')?.addEventListener('click', (e) => {
let tokenInput = document.querySelector('input[name=token]');
if (tokenInput.value) {
return this.completePaymentUsingToken(e);
}
return this.completePaymentWithoutToken(e);
});
Array.from(
document.getElementsByClassName('toggle-payment-with-token')
).forEach((element) =>
element.addEventListener('click', (element) => {
document
.getElementById('card-container')
.classList.add('hidden');
document.getElementById('save-card--container').style.display =
'none';
document.querySelector('input[name=token]').value =
element.target.dataset.token;
})
);
document
.getElementById('toggle-payment-with-credit-card')
?.addEventListener('click', async (element) => {
document
.getElementById('card-container')
.classList.remove('hidden');
document.getElementById('save-card--container').style.display =
'grid';
document.querySelector('input[name=token]').value = '';
});
let toggleWithToken = document.querySelector(
'.toggle-payment-with-token'
);
if (!toggleWithToken) {
document.getElementById('toggle-payment-with-credit-card')?.click();
}
}
}
new SquareCreditCard().handle();

View File

@ -2,6 +2,9 @@
=> ctrans('texts.payment_type_credit_card')]) => ctrans('texts.payment_type_credit_card')])
@section('gateway_head') @section('gateway_head')
<meta name="square-appId" content="{{ $gateway->company_gateway->getConfigField('applicationId') }}">
<meta name="square-locationId" content="{{ $gateway->company_gateway->getConfigField('locationId') }}">
<meta name="square-authorize" content="true">
@endsection @endsection
@section('gateway_content') @section('gateway_content')
@ -9,161 +12,26 @@
method="post" id="server_response"> method="post" id="server_response">
@csrf @csrf
<input type="text" name="sourceId" id="sourceId" hidden> <input type="text" name="sourceId" id="sourceId" hidden>
</form>
<div class="alert alert-failure mb-4" hidden id="errors"></div> <div class="alert alert-failure mb-4" hidden id="errors"></div>
@component('portal.ninja2020.components.general.card-element-single') @component('portal.ninja2020.components.general.card-element-single')
<div id="card-container"></div> <div id="card-container"></div>
<div id="payment-status-container"></div>
<div id="payment-status-container"></div>
</form>
@endcomponent @endcomponent
@component('portal.ninja2020.gateways.includes.pay_now') @component('portal.ninja2020.gateways.includes.pay_now', ['id' => 'authorize-card'])
{{ ctrans('texts.add_payment_method') }} {{ ctrans('texts.add_payment_method') }}
@endcomponent @endcomponent
@endsection @endsection
@section('gateway_footer') @section('gateway_footer')
@if ($gateway->company_gateway->getConfigField('testMode'))
<script type="text/javascript" src="https://sandbox.web.squarecdn.com/v1/square.js"></script>
@else
<script type="text/javascript" src="https://web.squarecdn.com/v1/square.js"></script>
@endif
@if($gateway->company_gateway->getConfigField('testMode')) <script src="{{ asset('js/clients/payments/square-credit-card.js') }}"></script>
<script type="text/javascript" src="https://sandbox.web.squarecdn.com/v1/square.js"></script> @endsection
@else
<script type="text/javascript" src="https://web.squarecdn.com/v1/square.js"></script>
@endif
<script>
const appId = "{{ $gateway->company_gateway->getConfigField('applicationId') }}";
const locationId = "{{ $gateway->company_gateway->getConfigField('locationId') }}";
const darkModeCardStyle = {
'.input-container': {
borderColor: '#2D2D2D',
borderRadius: '6px',
},
'.input-container.is-focus': {
borderColor: '#006AFF',
},
'.input-container.is-error': {
borderColor: '#ff1600',
},
'.message-text': {
color: '#999999',
},
'.message-icon': {
color: '#999999',
},
'.message-text.is-error': {
color: '#ff1600',
},
'.message-icon.is-error': {
color: '#ff1600',
},
input: {
backgroundColor: '#2D2D2D',
color: '#FFFFFF',
fontFamily: 'helvetica neue, sans-serif',
},
'input::placeholder': {
color: '#999999',
},
'input.is-error': {
color: '#ff1600',
},
};
async function initializeCard(payments) {
const card = await payments.card({
style: darkModeCardStyle,
});
await card.attach('#card-container');
return card;
}
async function tokenize(paymentMethod) {
const tokenResult = await paymentMethod.tokenize();
if (tokenResult.status === 'OK') {
return tokenResult.token;
} else {
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors
)}`;
}
throw new Error(errorMessage);
}
}
// status is either SUCCESS or FAILURE;
function displayPaymentResults(status) {
const statusContainer = document.getElementById(
'payment-status-container'
);
if (status === 'SUCCESS') {
statusContainer.classList.remove('is-failure');
statusContainer.classList.add('is-success');
} else {
statusContainer.classList.remove('is-success');
statusContainer.classList.add('is-failure');
}
statusContainer.style.visibility = 'visible';
}
document.addEventListener('DOMContentLoaded', async function () {
if (!window.Square) {
throw new Error('Square.js failed to load properly');
}
let payments;
try {
payments = window.Square.payments(appId, locationId);
} catch {
const statusContainer = document.getElementById(
'payment-status-container'
);
statusContainer.className = 'missing-credentials';
statusContainer.style.visibility = 'visible';
return;
}
let card;
try {
card = await initializeCard(payments);
} catch (e) {
console.error('Initializing Card failed', e);
return;
}
async function handlePaymentMethodSubmission(event, paymentMethod) {
event.preventDefault();
try {
// disable the submit button as we await tokenization and make a payment request.
cardButton.disabled = true;
const token = await tokenize(paymentMethod);
document.getElementById('sourceId').value = token;
document.getElementById('server_response').submit();
displayPaymentResults('SUCCESS');
} catch (e) {
cardButton.disabled = false;
displayPaymentResults('FAILURE');
console.error(e.message);
}
}
const cardButton = document.getElementById('pay-now');
cardButton.addEventListener('click', async function (event) {
await handlePaymentMethodSubmission(event, card);
});
});
</script>
@endsection

View File

@ -2,170 +2,66 @@
=> ctrans('texts.payment_type_credit_card')]) => ctrans('texts.payment_type_credit_card')])
@section('gateway_head') @section('gateway_head')
<meta name="square-appId" content="{{ $gateway->company_gateway->getConfigField('applicationId') }}">
<meta name="square-locationId" content="{{ $gateway->company_gateway->getConfigField('locationId') }}">
@endsection @endsection
@section('gateway_content') @section('gateway_content')
<form action="{{ route('client.payments.response') }}" method="post" id="server_response">
<form action="{{ route('client.payment_methods.store', ['method' => App\Models\GatewayType::CREDIT_CARD]) }}" method="post" id="server_response">
@csrf @csrf
<input type="hidden" name="store_card"> <input type="hidden" name="store_card">
<input type="text" name="sourceId" id="sourceId" hidden> <input type="hidden" name="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="company_gateway_id" value="{{ $gateway->getCompanyGatewayId() }}">
<input type="hidden" name="payment_method_id" value="{{ $payment_method_id }}">
<input type="hidden" name="token">
<input type="hidden" name="sourceId" id="sourceId">
</form> </form>
<div class="alert alert-failure mb-4" hidden id="errors"></div> <div class="alert alert-failure mb-4" hidden id="errors"></div>
@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)
<label class="mr-4">
<input type="radio" data-token="{{ $token->token }}" name="payment-type"
class="form-radio cursor-pointer toggle-payment-with-token" />
<span class="ml-1 cursor-pointer">**** {{ optional($token->meta)->last4 }}</span>
</label>
@endforeach
@endisset
<label>
<input type="radio" id="toggle-payment-with-credit-card" class="form-radio cursor-pointer" name="payment-type"
checked />
<span class="ml-1 cursor-pointer">{{ __('texts.new_card') }}</span>
</label>
@endcomponent
@include('portal.ninja2020.gateways.includes.save_card')
@component('portal.ninja2020.components.general.card-element-single') @component('portal.ninja2020.components.general.card-element-single')
<div id="card-container"></div> <div id="card-container"></div>
<div id="payment-status-container"></div>
<div id="payment-status-container"></div>
</form>
@endcomponent @endcomponent
@component('portal.ninja2020.gateways.includes.pay_now') @include('portal.ninja2020.gateways.includes.pay_now')
{{ ctrans('texts.pay_now') }}
@endcomponent
@endsection @endsection
@section('gateway_footer') @section('gateway_footer')
@if ($gateway->company_gateway->getConfigField('testMode'))
<script type="text/javascript" src="https://sandbox.web.squarecdn.com/v1/square.js"></script>
@else
<script type="text/javascript" src="https://web.squarecdn.com/v1/square.js"></script>
@endif
@if($gateway->company_gateway->getConfigField('testMode')) <script src="{{ asset('js/clients/payments/square-credit-card.js') }}"></script>
<script type="text/javascript" src="https://sandbox.web.squarecdn.com/v1/square.js"></script>
@else
<script type="text/javascript" src="https://web.squarecdn.com/v1/square.js"></script>
@endif
<script>
const appId = "{{ $gateway->company_gateway->getConfigField('applicationId') }}";
const locationId = "{{ $gateway->company_gateway->getConfigField('locationId') }}";
const darkModeCardStyle = {
'.input-container': {
borderColor: '#2D2D2D',
borderRadius: '6px',
},
'.input-container.is-focus': {
borderColor: '#006AFF',
},
'.input-container.is-error': {
borderColor: '#ff1600',
},
'.message-text': {
color: '#999999',
},
'.message-icon': {
color: '#999999',
},
'.message-text.is-error': {
color: '#ff1600',
},
'.message-icon.is-error': {
color: '#ff1600',
},
input: {
backgroundColor: '#2D2D2D',
color: '#FFFFFF',
fontFamily: 'helvetica neue, sans-serif',
},
'input::placeholder': {
color: '#999999',
},
'input.is-error': {
color: '#ff1600',
},
};
async function initializeCard(payments) {
const card = await payments.card({
style: darkModeCardStyle,
});
await card.attach('#card-container');
return card;
}
async function tokenize(paymentMethod) {
const tokenResult = await paymentMethod.tokenize();
if (tokenResult.status === 'OK') {
return tokenResult.token;
} else {
let errorMessage = `Tokenization failed with status: ${tokenResult.status}`;
if (tokenResult.errors) {
errorMessage += ` and errors: ${JSON.stringify(
tokenResult.errors
)}`;
}
throw new Error(errorMessage);
}
}
// status is either SUCCESS or FAILURE;
function displayPaymentResults(status) {
const statusContainer = document.getElementById(
'payment-status-container'
);
if (status === 'SUCCESS') {
statusContainer.classList.remove('is-failure');
statusContainer.classList.add('is-success');
} else {
statusContainer.classList.remove('is-success');
statusContainer.classList.add('is-failure');
}
statusContainer.style.visibility = 'visible';
}
document.addEventListener('DOMContentLoaded', async function () {
if (!window.Square) {
throw new Error('Square.js failed to load properly');
}
let payments;
try {
payments = window.Square.payments(appId, locationId);
} catch {
const statusContainer = document.getElementById(
'payment-status-container'
);
statusContainer.className = 'missing-credentials';
statusContainer.style.visibility = 'visible';
return;
}
let card;
try {
card = await initializeCard(payments);
} catch (e) {
console.error('Initializing Card failed', e);
return;
}
async function handlePaymentMethodSubmission(event, paymentMethod) {
event.preventDefault();
try {
// disable the submit button as we await tokenization and make a payment request.
cardButton.disabled = true;
const token = await tokenize(paymentMethod);
document.getElementById('sourceId').value = token;
document.getElementById('server_response').submit();
displayPaymentResults('SUCCESS');
} catch (e) {
cardButton.disabled = false;
displayPaymentResults('FAILURE');
console.error(e.message);
}
}
const cardButton = document.getElementById('pay-now');
cardButton.addEventListener('click', async function (event) {
await handlePaymentMethodSubmission(event, card);
});
});
</script>
@endsection @endsection

View File

@ -0,0 +1,128 @@
<?php
/**
* 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://www.elastic.co/licensing/elastic-license
*/
namespace Tests\Browser\ClientPortal\Gateways\Square;
use Laravel\Dusk\Browser;
use Tests\Browser\Pages\ClientPortal\Login;
use Tests\DuskTestCase;
class CreditCardTest extends DuskTestCase
{
protected function setUp(): void
{
parent::setUp();
foreach (static::$browsers as $browser) {
$browser->driver->manage()->deleteAllCookies();
}
$this->browse(function (Browser $browser) {
$browser
->visit(new Login())
->auth();
});
}
public function testPaymentWithNewCard()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('Credit Card')
->type('#cardholder-name', 'John Doe')
->withinFrame('iframe', function (Browser $browser) {
$browser
->type('#cardNumber', '4111 1111 1111 1111')
->type('#expirationDate', '04/22')
->type('#cvv', '1111')
->type('#postalCode', '12345');
})
->click('#pay-now')
->waitForText('Details of the payment', 60);
});
}
public function testPayWithNewCardAndSaveForFutureUse()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('Credit Card')
->type('#cardholder-name', 'John Doe')
->withinFrame('iframe', function (Browser $browser) {
$browser
->type('#cardNumber', '4111 1111 1111 1111')
->type('#expirationDate', '04/22')
->type('#cvv', '1111')
->type('#postalCode', '12345');
})
->radio('#proxy_is_default', true)
->click('#pay-now')
->waitForText('Details of the payment', 60)
->visitRoute('client.payment_methods.index')
->clickLink('View')
->assertSee('4242');
});
}
public function testPayWithSavedCreditCard()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('Credit Card')
->click('.toggle-payment-with-token')
->click('#pay-now')
->waitForText('Details of the payment', 60);
});
}
public function testRemoveCreditCard()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.payment_methods.index')
->clickLink('View')
->press('Remove Payment Method')
->waitForText('Confirmation')
->click('@confirm-payment-removal')
->assertSee('Payment method has been successfully removed.');
});
}
public function testAddingCreditCardStandalone()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.payment_methods.index')
->press('Add Payment Method')
->clickLink('Credit Card')
->type('#cardholder-name', 'John Doe')
->withinFrame('iframe', function (Browser $browser) {
$browser
->type('#cardNumber', '4111 1111 1111 1111')
->type('#expirationDate', '04/22')
->type('#cvv', '1111')
->type('#postalCode', '12345');
})
->press('Add Payment Method')
->waitForText('**** 1111');
});
}
}

4
webpack.mix.js vendored
View File

@ -89,6 +89,10 @@ mix.js("resources/js/app.js", "public/js")
.js( .js(
"resources/js/clients/payments/mollie-credit-card.js", "resources/js/clients/payments/mollie-credit-card.js",
"public/js/clients/payments/mollie-credit-card.js" "public/js/clients/payments/mollie-credit-card.js"
)
.js(
"resources/js/clients/payments/square-credit-card.js",
"public/js/clients/payments/square-credit-card.js"
); );
mix.copyDirectory('node_modules/card-js/card-js.min.css', 'public/css/card-js.min.css'); mix.copyDirectory('node_modules/card-js/card-js.min.css', 'public/css/card-js.min.css');