510 lines
18 KiB
PHP
510 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use KupShop\AdminBundle\Util\ActivityLog;
|
|
use KupShop\KupShopBundle\Config;
|
|
use KupShop\KupShopBundle\Query\JsonOperator;
|
|
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
|
|
use KupShop\OrderingBundle\Exception\PaymentException;
|
|
use Query\Operator;
|
|
|
|
/**
|
|
* Implementace pouze pro "Immediate Capture"
|
|
* Klient musí mít v twisto portalu povoleno "Okamžité odbavení" (Immediate Capture).
|
|
*/
|
|
class TwistoPay extends Payment
|
|
{
|
|
public static $name = 'Twisto Pay';
|
|
|
|
public $class = 'TwistoPay';
|
|
|
|
protected ?string $defaultIcon = '../../common/static/payments/twisto_pay.svg';
|
|
|
|
public static bool $canAutoReturn = true;
|
|
|
|
protected string $apiUrl = 'https://api.twisto.cz/psp/smi';
|
|
|
|
protected $pay_method = Payment::METHOD_ONLINE;
|
|
|
|
public function getPaymentUrl()
|
|
{
|
|
return createScriptURL([
|
|
's' => 'payment',
|
|
'IDo' => $this->order->id,
|
|
'cf' => $this->order->getSecurityCode(),
|
|
'step' => 1,
|
|
'class' => $this->class,
|
|
'absolute' => true,
|
|
]);
|
|
}
|
|
|
|
public function processStep_1()
|
|
{
|
|
if ($paymentExists = $this->checkPaymentAlreadyExists()) {
|
|
if (empty($paymentExists['sessionId'])) {
|
|
$this->info(translate('payment_already_exists', 'payment'));
|
|
}
|
|
$redirectExists = $this->requestCheckoutData($paymentExists['sessionId']);
|
|
if (empty($redirectExists['uri'])) {
|
|
$this->info(translate('payment_already_exists', 'payment'));
|
|
}
|
|
redirection($redirectExists['uri']);
|
|
}
|
|
|
|
$checkoutData = $this->getCheckoutData();
|
|
|
|
$redirectData = $this->getCheckoutRedirect($checkoutData);
|
|
|
|
if (empty($redirectData['id']) || empty($redirectData['uri'])) {
|
|
$this->createApiException(['message' => 'ID or URI not received', 'id' => $redirectData['id'], 'uri' => $redirectData['uri']]);
|
|
}
|
|
|
|
$this->createPayment($redirectData['id'], $redirectData['order']['amount'], ['paymentClass' => $this->class]);
|
|
|
|
redirection($redirectData['uri']);
|
|
}
|
|
|
|
protected function checkPaymentAlreadyExists()
|
|
{
|
|
return sqlQueryBuilder()->select("op.id, JSON_UNQUOTE(JSON_EXTRACT(op.payment_data, '$.session')) sessionId")->from('order_payments', 'op')
|
|
->innerJoin('op', 'orders', 'o', 'o.id = op.id_order')
|
|
->where(Operator::equals(['o.order_no' => $this->order->order_no]))
|
|
->andWhere(Operator::orX(
|
|
JsonOperator::contains('op.payment_data', 'paymentClass', 'TwistoPay'),
|
|
JsonOperator::contains('op.payment_data', 'paymentClass', 'TwistoPayIn3')
|
|
))->execute()->fetchAssociative();
|
|
}
|
|
|
|
public function processStep_2()
|
|
{
|
|
$checkoutId = getVal('checkout_id');
|
|
$status = getVal('status');
|
|
|
|
switch ($status) {
|
|
case 'captured':
|
|
case 'authorized':
|
|
$this->setStatus(Payment::STATUS_FINISHED, $checkoutId);
|
|
$this->info(translate('paymentSuccess', 'payment'));
|
|
break;
|
|
case 'rejected':
|
|
$this->setStatus(Payment::STATUS_STORNO, $checkoutId);
|
|
$this->info(translate('payment_rejected_status', 'payment'));
|
|
// no break
|
|
case 'error':
|
|
$this->setStatus(Payment::STATUS_STORNO, $checkoutId);
|
|
$this->info(translate('payment_unexpected_status', 'payment'));
|
|
}
|
|
}
|
|
|
|
protected function getCheckoutRedirect($checkoutData)
|
|
{
|
|
$response = $this->requestCurl('/checkouts', json_encode($checkoutData));
|
|
|
|
return json_decode($response, true);
|
|
}
|
|
|
|
protected function getCheckoutData()
|
|
{
|
|
$items = [];
|
|
foreach ($this->order->fetchItems() as $item) {
|
|
$items[] = $this->getCheckoutItem($item);
|
|
}
|
|
|
|
$this->setItemsRoundingDiscount($items);
|
|
|
|
$requestBody = [
|
|
'type' => $this->getCheckoutType(),
|
|
'config' => [
|
|
'redirect_uri' => $this->getGenericPaymentUrl(2),
|
|
],
|
|
'shopper' => $this->removeEmptyValues([
|
|
'first_name' => $this->order->invoice_name,
|
|
'last_name' => $this->order->invoice_surname,
|
|
'phone' => $this->order->invoice_phone,
|
|
'billing_address' => $this->removeEmptyValues([
|
|
'line1' => $this->order->invoice_street,
|
|
'line2' => $this->order->invoice_custom_address,
|
|
'city' => $this->order->invoice_city,
|
|
'state' => $this->order->invoice_state,
|
|
'postal_code' => $this->order->invoice_zip,
|
|
'country' => $this->order->invoice_country,
|
|
]),
|
|
'statistics' => $this->getCheckoutUserStatistics($this->order->id_user, $this->order->invoice_email),
|
|
// "personal_id" => $this->order->id_user ?? '',
|
|
'email' => $this->order->invoice_email,
|
|
]),
|
|
'order' => $this->removeEmptyValues([
|
|
'reference' => $this->order->order_no,
|
|
'amount' => $this->getOrderPrice(),
|
|
'currency' => $this->order->getTotalPrice()->getCurrency()->getId(),
|
|
'items' => $items,
|
|
]),
|
|
];
|
|
|
|
/** @var LoggerInterface $logger */
|
|
$logger = ServiceContainer::getService('logger');
|
|
$logger->notice('Twisto - objednavka', [
|
|
'Request' => $requestBody,
|
|
]);
|
|
|
|
return $requestBody;
|
|
}
|
|
|
|
protected function removeEmptyValues(array $array): array
|
|
{
|
|
foreach ($array as $key => $value) {
|
|
if ((is_string($value) && !$value) || is_null($value)) {
|
|
unset($array[$key]);
|
|
}
|
|
}
|
|
|
|
return $array;
|
|
}
|
|
|
|
protected function getCheckoutUserStatistics($idUser, string $email): array
|
|
{
|
|
$userStats = [];
|
|
|
|
if ($idUser) {
|
|
$user = sqlQueryBuilder()->select('*')->from('users')->where(Operator::equals(['id' => $this->order->id_user]))
|
|
->execute()->fetchAssociative();
|
|
|
|
if ($user['date_reg'] && $user['date_reg'] !== '0000-00-00 00:00:00') {
|
|
$userStats['account_created'] = (new DateTime($user['date_reg']))->format('Y-m-d\TH:i:s\Z');
|
|
}
|
|
$userStats['currency'] = $user['currency'];
|
|
}
|
|
|
|
$prevOrdersStats = sqlQueryBuilder()->select('AVG(o.total_price) avg, SUM(o.total_price) sum, MAX(o.total_price) max, COUNT(*) count')->from('orders',
|
|
'o')
|
|
->where(Operator::equals($idUser ? ['id_user' => $idUser] : ['invoice_email' => $email]))
|
|
->andWhere('status_storno = 0')
|
|
->andWhere(Operator::inIntArray(getStatuses('handled'), 'o.status'))
|
|
->groupBy($idUser ? 'id_user' : 'invoice_email')
|
|
->execute()->fetchAssociative() ?: [];
|
|
|
|
$stats = ($prevOrdersStats['count'] ?? false) ? [
|
|
'sales_total_count' => $prevOrdersStats['count'] ?? 0,
|
|
'sales_total_amount' => $prevOrdersStats['sum'] ?? 0,
|
|
'sales_avg_amount' => $prevOrdersStats['avg'] ?? 0,
|
|
'sales_max_amount' => $prevOrdersStats['max'] ?? 0,
|
|
'has_previous_purchases' => true,
|
|
] : ['has_previous_purchases' => false];
|
|
|
|
// skipped fields
|
|
// "refunds_total_amount" => "string",
|
|
// "previous_chargeback" => true,
|
|
// "last_login" => "2019-08-24T14:15:22Z",
|
|
return array_merge($userStats, $stats);
|
|
}
|
|
|
|
protected function getCheckoutItem(array $item): array
|
|
{
|
|
if (($item['note']['item_type'] ?? '') == 'delivery') {
|
|
$type = 'shipping';
|
|
} elseif (($item['note']['item_type'] ?? '') == 'discount') {
|
|
$type = 'discount';
|
|
} elseif (!empty($item['note']['bonus_points'])) {
|
|
$type = 'store_credit';
|
|
} else {
|
|
$type = 'sku';
|
|
}
|
|
|
|
return [
|
|
'name' => $item['descr'],
|
|
'amount' => $item['piece_price']['value_with_vat']->asFloat(),
|
|
'quantity' => (int) $item['pieces'],
|
|
'type' => $type,
|
|
];
|
|
}
|
|
|
|
protected function setItemsRoundingDiscount(&$items)
|
|
{
|
|
$itemsPrice = array_sum(array_map(function ($item) {
|
|
return $item['amount'] * $item['quantity'];
|
|
}, $items));
|
|
|
|
$difference = $this->getOrderPrice() - $itemsPrice;
|
|
if (abs($difference) > PHP_FLOAT_EPSILON && abs($difference) < 10) {
|
|
$items[] = [
|
|
'name' => 'Rounding discount',
|
|
'amount' => round($difference, 3),
|
|
'quantity' => 1,
|
|
'type' => ($difference < 0) ? 'discount' : 'sku',
|
|
];
|
|
}
|
|
}
|
|
|
|
protected function getOrderPrice(): float
|
|
{
|
|
return $this->order->getTotalPrice()->getPriceWithVat()->asFloat();
|
|
}
|
|
|
|
public function createPayment($session, $price = null, $data = [])
|
|
{
|
|
$fields = [
|
|
'id_order' => $this->orderId,
|
|
'price' => $price,
|
|
'note' => "Platba modulu {$this->class}",
|
|
'status' => self::STATUS_CREATED,
|
|
'payment_data' => json_encode(array_merge($data, ['session' => $session])),
|
|
'date' => date('Y-m-d H:i:s'),
|
|
'method' => self::METHOD_ONLINE,
|
|
];
|
|
|
|
$this->insertSQL('order_payments', $fields);
|
|
|
|
$this->status = self::STATUS_CREATED;
|
|
$this->paymentId = (int) sqlInsertId();
|
|
|
|
$this->order->updatePayments();
|
|
|
|
return true;
|
|
}
|
|
|
|
protected function requestCheckoutData($checkoutId)
|
|
{
|
|
$response = $this->requestCurl('/checkouts/'.$checkoutId, '', false);
|
|
|
|
return json_decode($response, true);
|
|
}
|
|
|
|
protected function requestRefund($checkoutId, $amount, $reference)
|
|
{
|
|
$body = $this->removeEmptyValues([
|
|
'checkout_id' => $checkoutId,
|
|
'amount' => abs($amount),
|
|
'reference' => $reference,
|
|
]);
|
|
|
|
return $this->requestCurl('/refunds', json_encode($body));
|
|
}
|
|
|
|
protected function requestCheckoutCancel($checkoutId)
|
|
{
|
|
return $this->requestCurl("/checkouts/{$checkoutId}/cancel", '');
|
|
}
|
|
|
|
protected function requestCurl(string $path, string $encodedBody, $post = true)
|
|
{
|
|
$url = $this->getApiUrl().$path;
|
|
|
|
$ch = curl_init();
|
|
|
|
if ($post) {
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $encodedBody);
|
|
}
|
|
|
|
curl_setopt($ch, CURLOPT_URL, $url);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
'Authorization: Bearer '.$this->getAccessToken(),
|
|
'Content-Type: application/json',
|
|
]);
|
|
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
|
|
|
$response = curl_exec($ch);
|
|
$responseInfo = curl_getinfo($ch);
|
|
$curl_errno = curl_errno($ch);
|
|
curl_close($ch);
|
|
|
|
if ($responseInfo['http_code'] >= 300 || $responseInfo['http_code'] === 0) {
|
|
$debugInfo = ['url' => $url, 'responseInfo' => $responseInfo, 'curl_errno' => $curl_errno, 'RESPONSE' => $response];
|
|
$this->createApiException($debugInfo);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
protected function createApiException($debugInfo)
|
|
{
|
|
$errMessage = 'Komunikace s platební bránou Twisto selhala. Zkuste prosím znovu později.';
|
|
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_COMMUNICATION, $errMessage, $debugInfo);
|
|
getRaven()->captureMessage('Twisto API error', [], ['extra' => $debugInfo]);
|
|
throw new PaymentException($errMessage);
|
|
}
|
|
|
|
protected function createWebhookError($httpCode, $debugInfo, $responseText = '')
|
|
{
|
|
$debugInfo = is_array($debugInfo) ? $debugInfo : [$debugInfo];
|
|
$errMessage = 'Twisto webhook se nepodařilo zpracovat.';
|
|
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_COMMUNICATION, $errMessage, $debugInfo);
|
|
getRaven()->captureMessage('Twisto webhook error', [], ['extra' => $debugInfo]);
|
|
$this->sendNotificationResponse($httpCode, $responseText);
|
|
}
|
|
|
|
/**
|
|
* Webhook handler.
|
|
*/
|
|
public function processStep_10()
|
|
{
|
|
if (!$this->request->isMethod('POST')) {
|
|
$this->sendNotificationResponse(405, '');
|
|
}
|
|
|
|
try {
|
|
$requestBody = $this->request->toArray();
|
|
} catch (Exception $e) {
|
|
$this->createWebhookError(422, $this->request->getContent(), 'Unreadable JSON');
|
|
}
|
|
|
|
$checkoutId = getVal('checkout_id', $requestBody);
|
|
$state = getVal('state', $requestBody);
|
|
|
|
if (!$checkoutId) {
|
|
$this->createWebhookError(400, array_merge($requestBody, ['err' => 'missing checkout id']), '');
|
|
}
|
|
|
|
$idOrder = $this->getOrderId($checkoutId);
|
|
|
|
if (!$idOrder) {
|
|
$this->sendNotificationResponse(400, 'Order not found');
|
|
}
|
|
|
|
$this->setOrder($idOrder);
|
|
$this->getStatus($checkoutId);
|
|
$this->updateStatus($state, $checkoutId);
|
|
|
|
$this->sendNotificationResponse(200, 'OK');
|
|
}
|
|
|
|
public function checkPaidOrders()
|
|
{
|
|
$orderPayments = sqlQueryBuilder()->select('op.id, op.id_order, op.payment_data')
|
|
->from('order_payments', 'op')
|
|
->where(\Query\Operator::inIntArray([
|
|
static::STATUS_CREATED,
|
|
static::STATUS_PENDING,
|
|
], 'op.status'))
|
|
->andWhere('op.date > (DATE_SUB(CURDATE(), INTERVAL 1 MONTH))')
|
|
->andWhere(Operator::inStringArray(['TwistoPay', 'TwistoPayIn3'],
|
|
JsonOperator::value('op.payment_data', 'paymentClass')))
|
|
->andWhere(JsonOperator::exists('op.payment_data', 'session'))
|
|
->execute();
|
|
|
|
foreach ($orderPayments as $orderPayment) {
|
|
$paymentData = json_decode($orderPayment['payment_data'], true);
|
|
if (empty($paymentData['session'])) {
|
|
continue;
|
|
}
|
|
|
|
$this->setOrder($orderPayment['id_order']);
|
|
$checkoutId = $paymentData['session'];
|
|
|
|
$response = $this->requestCheckoutData($checkoutId);
|
|
|
|
$this->updateStatus($response['state'], $checkoutId);
|
|
}
|
|
}
|
|
|
|
protected function updateStatus($state, $checkoutId)
|
|
{
|
|
switch ($state) {
|
|
case 'created':
|
|
$this->setStatus(Payment::STATUS_PENDING, $checkoutId);
|
|
break;
|
|
case 'approved':
|
|
case 'completed':
|
|
$this->setStatus(Payment::STATUS_FINISHED, $checkoutId);
|
|
break;
|
|
case 'cancelled':
|
|
case 'error':
|
|
$this->setStatus(Payment::STATUS_STORNO, $checkoutId);
|
|
}
|
|
}
|
|
|
|
public function doReturnPayment(array $payment, float $amount)
|
|
{
|
|
if ($payment['status'] != Payment::STATUS_FINISHED) {
|
|
throw new PaymentException(translate('returnFailed', 'orderPayment'));
|
|
}
|
|
|
|
$checkoutId = $payment['payment_data']['session'] ?? null;
|
|
|
|
if (!$checkoutId) {
|
|
throw new PaymentException('Payment does not have assigned checkout_id');
|
|
}
|
|
|
|
$checkoutData = $this->requestCheckoutData($checkoutId);
|
|
|
|
switch ($checkoutData['state']) {
|
|
case 'completed':
|
|
$reference = $checkoutData['order']['reference'].'_'.$payment['id'].'_'.rand();
|
|
$response = $this->requestRefund($checkoutId, $amount, $reference);
|
|
break;
|
|
case 'created':
|
|
case 'approved':
|
|
if ($checkoutData['order']['amount'] + $amount > PHP_FLOAT_EPSILON) {
|
|
$message = translate('returnFailedOnlyFullAmount', 'orderPayment');
|
|
addActivityLog(ActivityLog::SEVERITY_ERROR,
|
|
ActivityLog::TYPE_COMMUNICATION,
|
|
$message,
|
|
['amount' => $amount, 'payment_id' => $checkoutData, 'RESPONSE' => $checkoutData['state']]);
|
|
throw new PaymentException($message);
|
|
}
|
|
$response = $this->requestCheckoutCancel($checkoutId);
|
|
break;
|
|
default:
|
|
$message = translate('returnFailedInvalidState', 'orderPayment').": {$checkoutData['state']}";
|
|
addActivityLog(ActivityLog::SEVERITY_ERROR,
|
|
ActivityLog::TYPE_COMMUNICATION,
|
|
$message,
|
|
['amount' => $amount, 'payment_id' => $checkoutData, 'RESPONSE' => $checkoutData['state']]);
|
|
throw new PaymentException($message);
|
|
}
|
|
|
|
return ['response' => $response];
|
|
}
|
|
|
|
public static function getSettingsConfiguration(): array
|
|
{
|
|
return [
|
|
'fields' => [
|
|
'token' => [
|
|
'title' => 'Token',
|
|
'type' => 'text',
|
|
],
|
|
'webhook' => [
|
|
'title' => 'Webhook url',
|
|
'text' => path('kupshop_ordering_payment_legacypayment', ['class' => 'TwistoPay', 'step' => 10], \Symfony\Component\Routing\Router::ABSOLUTE_URL),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function getCheckoutType(): string
|
|
{
|
|
return 'standard';
|
|
}
|
|
|
|
public function hasOnlinePayment()
|
|
{
|
|
return true;
|
|
}
|
|
|
|
protected function getApiUrl(): string
|
|
{
|
|
return $this->apiUrl;
|
|
}
|
|
|
|
protected function getAccessToken(): string
|
|
{
|
|
return $this->config['token'] ?? '';
|
|
}
|
|
|
|
public static function isEnabled($className)
|
|
{
|
|
$cfg = Config::get();
|
|
|
|
if (empty($cfg['Modules']['payments'][$className])) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|