Files
kupshop/class/payments/class.TwistoPay.php
2025-08-02 16:30:27 +02:00

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;
}
}