first commit

This commit is contained in:
2025-08-02 16:30:27 +02:00
commit 23646bfcee
14851 changed files with 1750626 additions and 0 deletions

View File

@@ -0,0 +1,395 @@
<?php
use KupShop\AdminBundle\Util\ActivityLog;
use KupShop\KupShopBundle\Context\CurrencyContext;
use KupShop\KupShopBundle\Context\DomainContext;
use KupShop\KupShopBundle\Context\LanguageContext;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\KupShopBundle\Util\EANValidator;
use KupShop\OrderingBundle\Exception\PaymentException;
use KupShop\OrderingBundle\Util\Order\PaymentOrderSubMethodTrait;
use ThePay\ApiClient\Exception\ApiException;
use ThePay\ApiClient\Exception\MissingExtensionException;
use ThePay\ApiClient\Filter\PaymentMethodFilter;
use ThePay\ApiClient\Model\CreatePaymentCustomer;
use ThePay\ApiClient\Model\CreatePaymentItem;
use ThePay\ApiClient\ValueObject\LanguageCode;
/**
* composer require "thepay/api-client":"1.2.4".
*/
class ThePay20 extends Payment
{
use PaymentOrderSubMethodTrait;
public static $name = 'ThePay 2.0 platební brána';
public static bool $canAutoReturn = true;
protected $templateCart = 'payment.ThePay20.cart.tpl';
public $class = 'ThePay20';
public $method;
protected $pay_method = Payment::METHOD_ONLINE;
protected $apiContext;
protected $gatewayUrl;
/** Get payment icon url and name.
* @return array|null
*/
public function getIcon()
{
$method = getVal($this->method, $this->getAvailableMethods());
if ($method) {
return [
'url' => $method['image'],
'name' => $method['name'],
];
}
return null;
}
public function getGatewayUrl(): string
{
if (!isset($this->gatewayUrl)) {
$this->processStep_1(false);
}
return $this->gatewayUrl;
}
public function processStep_1($redirectToGateway = true)
{
$apiContext = $this->getApiContext();
if (empty($this->method)) {
$paymentData = $this->order->getData('payment_data');
if ($paymentData) {
$this->loadPaymentInfo($paymentData);
}
}
$paymentUid = uniqid($this->order->id.'_', true);
$currencyContext = Contexts::get(CurrencyContext::class);
$params = new \ThePay\ApiClient\Model\CreatePaymentParams(
$amount = toDecimal($this->order->getRemainingPayment())
->mul(DecimalConstants::hundred())->asInteger(),
$currencyContext->getActiveId(),
$paymentUid
);
$params->setReturnUrl($this->getGenericPaymentUrl(2));
$params->setNotifUrl($this->getGenericPaymentUrl(10));
$params->setOrderId($this->order->order_no);
// $params->setDescriptionForCustomer($this->order->order_no);
// $params->setDescriptionForMerchant($this->order->order_no);
$params->setCustomer(new CreatePaymentCustomer(
$this->order->invoice_name,
$this->order->invoice_surname,
$this->order->invoice_email,
$this->order->invoice_phone,
($this->order->invoice_country != '' && $this->order->invoice_city != '' && $this->order->invoice_zip != '' && $this->order->invoice_street != '') ?
new \ThePay\ApiClient\Model\Address(
$this->order->invoice_country,
$this->order->invoice_city,
$this->order->invoice_zip,
$this->order->invoice_street
) : null
));
foreach ($this->order->fetchItems() as $item) {
if ($item['pieces'] < 1) {
continue;
}
$tmpItem = [
'type' => is_null($item['id_product']) ? 'delivery' : 'item',
'name' => $item['descr'],
'amount' => $item['total_price']['value_with_vat']->mul(DecimalConstants::hundred())->asInteger(),
'count' => (int) $item['pieces'],
];
if (isset($item['ean']) && EANValidator::checkEAN($item['ean'])) {
$tmpItem['ean'] = $item['ean'];
}
$params->addItem(
new CreatePaymentItem($tmpItem['type'], $tmpItem['name'], $tmpItem['amount'], $tmpItem['count'], $tmpItem['ean'] ?? null)
);
}
// check PaymentMethod beforehand: this should eliminate error "21 in not valid value"
// when the order was created with the old ThePay and then the eshop switched to ThePay20
try {
$tmp = new \ThePay\ApiClient\ValueObject\PaymentMethodCode($this->method);
$method = $this->method;
} catch (InvalidArgumentException $e) {
$method = null;
}
$response = $apiContext->createPayment($params, $method);
$this->gatewayUrl = $response->getPayUrl();
$this->createPayment(
$paymentUid,
Decimal::fromInteger($amount)->mul(Decimal::fromFloat(0.01))->asFloat(),
['paymentClass' => self::class]
);
if ($redirectToGateway) {
redirection($this->gatewayUrl);
}
}
/** Return from gateway */
public function processStep_2()
{
$this->checkPaymentStatus();
}
/** Webhook handler */
public function processStep_10()
{
$this->setIsNotification(true);
$this->checkPaymentStatus();
$this->sendNotificationResponse(200, 'OK');
}
private function checkPaymentStatus()
{
$paymentID = getVal('payment_uid', null, false);
if (!$paymentID) {
$this->error(translate('payment_id_missing', 'payment'));
}
$response = $this->getApiContext()->getPayment($paymentID);
$thirdPartyPaymentState = $response->getState(); // https://dataapi21.docs.apiary.io/#paymentStatesEnum
// determine kupshop unified payment state from third party payment state
if (in_array($thirdPartyPaymentState, ['waiting_for_payment', 'waiting_for_confirmation'])) {
$this->status = $unifiedState = Payment::STATUS_CREATED;
} elseif (in_array($thirdPartyPaymentState, ['paid'])) {
$this->status = $unifiedState = Payment::STATUS_FINISHED;
} elseif (in_array($thirdPartyPaymentState, [
'expired', 'preauth_cancelled', 'preauth_expired', 'partially_refunded',
])
) {
$this->status = $unifiedState = Payment::STATUS_STORNO;
} else {
logError(__FILE__, __LINE__, 'ThePay20 unexpected payment state "'.$thirdPartyPaymentState.'"');
$this->error(translate('payment_unexpected_status', 'payment'));
}
// change payment status
if (!$this->setStatus($unifiedState, $paymentID)) {
logError(__FILE__, __LINE__, 'ThePay20::updatePaymentStatus: setStatus failed!');
throw new \Exception('Set status failed');
}
$this->setPaymentSubMethod($response->getPaymentMethod());
switch ($unifiedState) {
case Payment::STATUS_FINISHED:
$this->success(translate('paymentSuccess', 'payment'));
break;
case Payment::STATUS_STORNO:
$this->info(translate('payment_storno', 'payment'));
break;
case Payment::STATUS_PENDING:
case Payment::STATUS_CREATED:
$this->info(translate('payment_waiting_for_confirmation', 'payment'));
break;
}
}
public function getApiContext(): ThePay\ApiClient\TheClient
{
if (!isset($this->apiContext)) {
$languageContext = Contexts::get(LanguageContext::class);
$apiContext = new \ThePay\ApiClient\TheClient(new \ThePay\ApiClient\TheConfig(
$this->config['merchantId'],
$this->config['projectId'],
$this->config['apiPassword'],
!empty($this->config['test']) ? 'https://demo.api.thepay.cz/' : 'https://api.thepay.cz/',
!empty($this->config['test']) ? 'https://demo.gate.thepay.cz/' : 'https://gate.thepay.cz/',
$this->getPreferredLanguage($this->config['language'] ?? $languageContext->getActiveId())
));
$this->apiContext = $apiContext;
}
return $this->apiContext;
}
public function getAvailableMethods()
{
$currencyContext = Contexts::get(CurrencyContext::class);
$currency = $currencyContext->getActiveId();
$domainContext = Contexts::get(DomainContext::class);
$domain = $domainContext->getActiveId();
$languageContext = Contexts::get(LanguageContext::class);
$language = $languageContext->getActiveId();
$cacheKey = "thepay20-methods-{$currency}-{$domain}-{$language}";
$methods = getCache($cacheKey);
if (!isset($methods) || $methods === false || !is_array($methods)) {
$methods = $this->fetchAvailableMethods($currency);
setCache($cacheKey, $methods);
}
return $methods;
}
public function fetchAvailableMethods($currency)
{
// check config presence
if (empty($this->config['merchantId'])
|| empty($this->config['projectId'])
|| empty($this->config['apiPassword'])
) {
addActivityLog(
ActivityLog::SEVERITY_ERROR,
ActivityLog::TYPE_COMMUNICATION,
'ThePay: Chybí údaje pro požadovaný jazyk v nastavení eshopu, případně využijte omezení platby/dopravy, aby se způsob doručení v tomto jazyce nenabízel.',
[
...ActivityLog::addObjectData([$this->getId() => $this->getName()], 'deliveryPayment'),
...['context-languageID' => Contexts::get(LanguageContext::class)->getActiveId()],
]
);
return [];
}
$overwriteMethodNames = $this->config['overwriteMethodNames'] ?? [];
$methods = [];
try {
$preferredLang = $this->getPreferredLanguage(
Contexts::get(LanguageContext::class)->getActiveId()
);
$overrideNamePerLang = [
'de' => ['card' => 'Kartenzahlung'], // preferred lang falls back to 'de' for 'at' too
'pl' => ['card' => 'Płatność kartą'],
];
foreach ($this->getApiContext()->getActivePaymentMethods(new PaymentMethodFilter([$currency], [], []), new LanguageCode($preferredLang)) as $method) {
$methods[$method->getCode()] = [
'name' => $overwriteMethodNames[$method->getCode()] ?? $overrideNamePerLang[$preferredLang][$method->getCode()] ?? $method->getTitle(),
'id' => $method->getCode(),
'image' => $method->getImageUrl()->getValue(),
];
}
} catch (ApiException|MissingExtensionException|\RuntimeException $e) {
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_COMMUNICATION, 'Chyba ThePay při načítání platebních metod: '.$e->getMessage());
$sentry = getRaven();
$sentry->captureException($e);
}
return $methods;
}
public function hasOnlinePayment()
{
return true;
}
public function doReturnPayment(array $payment, float $amount)
{
$amountCents = (int) floor($amount * -100); // musí být integer v centech
$full = (($payment['price'] + $amount) == 0);
try {
$this->getApiContext()->createPaymentRefund($payment['payment_data']['session'], $amountCents, 'vrácení '.(($full) ? 'platby' : 'přeplatku'));
$result = ['id' => $payment['payment_data']['session']];
return $result;
} catch (ApiException|InvalidArgumentException|RuntimeException $e) {
$message = translate('returnFailed', 'orderPayment');
$message .= translate('returnFailedMessage', 'orderPayment').$e->getMessage();
throw new PaymentException($message);
}
return true;
}
/** Check paid orders */
public function checkPaidOrders()
{
if (getVal('test', $this->config)) {
return false;
}
if (empty($this->config['merchantId']) || empty($this->config['projectId']) || empty($this->config['apiPassword'])) {
return false;
}
$context = $this->getApiContext();
$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,
static::STATUS_UNKNOWN,
], 'op.status'))
->andWhere('op.date > (DATE_SUB(CURDATE(), INTERVAL 1 MONTH))')
->execute();
foreach ($orderPayments as $orderPayment) {
$paymentData = json_decode($orderPayment['payment_data'], true);
if ($paymentData['paymentClass'] !== 'ThePay20' || empty($paymentData['session'])) {
continue;
}
$thirdPartyPaymentState = false;
try {
$response = $context->getPayment($paymentData['session']);
$thirdPartyPaymentState = $response->getState(); // https://dataapi21.docs.apiary.io/#paymentStatesEnum
} catch (ApiException|InvalidArgumentException $e) {
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_COMMUNICATION, 'ThePay error: '.$e->getMessage());
continue;
}
if ($thirdPartyPaymentState === 'paid') {
$this->setOrder($orderPayment['id_order']);
try {
$this->setStatus(Payment::STATUS_FINISHED, $paymentData['session']);
} catch (\KupShop\KupShopBundle\Exception\RedirectException $e) {
throw $e;
} catch (Exception $e) {
getRaven()->captureException($e);
}
} elseif ($thirdPartyPaymentState === 'expired' || $thirdPartyPaymentState === 'partially_refunded' || $thirdPartyPaymentState === 'preauth_cancelled') {
$this->setOrder($orderPayment['id_order']);
try {
$this->setStatus(Payment::STATUS_STORNO, $paymentData['session']);
} catch (\KupShop\KupShopBundle\Exception\RedirectException $e) {
throw $e;
} catch (Exception $e) {
getRaven()->captureException($e);
}
}
}
return 0;
}
private function getPreferredLanguage(string $language): string
{
$thePaySupportedLanguages = [
'aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az', 'ba', 'be', 'bg', 'bh', 'bi', 'bm', 'bn', 'bo',
'br', 'bs', 'ca', 'ce', 'ch', 'co', 'cr', 'cs', 'cu', 'cv', 'cy', 'da', 'de', 'dv', 'dz', 'ee', 'el', 'en', 'eo', 'es',
'et', 'eu', 'fa', 'ff', 'fi', 'fj', 'fo', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gu', 'gv', 'ha', 'he', 'hi', 'ho', 'hr',
'ht', 'hu', 'hy', 'hz', 'ia', 'id', 'ie', 'ig', 'ii', 'ik', 'io', 'is', 'it', 'iu', 'ja', 'jv', 'ka', 'kg', 'ki', 'kj',
'kk', 'kl', 'km', 'kn', 'ko', 'kr', 'ks', 'ku', 'kv', 'kw', 'ky', 'la', 'lb', 'lg', 'li', 'ln', 'lo', 'lt', 'lu', 'lv',
'mg', 'mh', 'mi', 'mk', 'ml', 'mn', 'mo', 'mr', 'ms', 'mt', 'my', 'na', 'nb', 'nd', 'ne', 'ng', 'nl', 'nn', 'no', 'nr',
'nv', 'ny', 'oc', 'oj', 'om', 'or', 'os', 'pa', 'pi', 'pl', 'ps', 'pt', 'qu', 'rm', 'rn', 'ro', 'ru', 'rw', 'sa', 'sc',
'sd', 'se', 'sg', 'sh', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg',
'th', 'ti', 'tk', 'tl', 'tn', 'to', 'tr', 'ts', 'tt', 'tw', 'ty', 'ug', 'uk', 'ur', 'uz', 've', 'vi', 'vo', 'wa', 'wo',
'xh', 'yi', 'yo', 'za', 'zh', 'zu',
];
$preferredLang = $language;
if (!in_array($preferredLang, $thePaySupportedLanguages)) {
if ($preferredLang === 'at') {
$preferredLang = 'de'; // use german language for at - austria
} else {
$preferredLang = 'en'; // if not supported fallback to english
}
}
return $preferredLang;
}
}