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