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

981 lines
29 KiB
PHP

<?php
use KupShop\AdminBundle\Util\ActivityLog;
use KupShop\KupShopBundle\Config;
use KupShop\KupShopBundle\Context\CurrencyContext;
use KupShop\KupShopBundle\Context\DomainContext;
use KupShop\KupShopBundle\Context\LanguageContext;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\KupShopBundle\Util\Contexts;
use KupShop\KupShopBundle\Util\Delivery\RestrictionParams;
use KupShop\KupShopBundle\Util\Delivery\RestrictionUtils;
use KupShop\OrderingBundle\Entity\Purchase\PurchaseState;
use KupShop\OrderingBundle\Exception\PaymentException;
use KupShop\OrderingBundle\Util\Order\OrderInfo;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
#[AllowDynamicProperties]
class Payment implements ArrayAccess
{
use DatabaseCommunication;
// Payment statuses
public const STATUS_UNKNOWN = -1;
public const STATUS_CREATED = 1;
public const STATUS_PENDING = 2;
public const STATUS_STORNO = 3;
public const STATUS_FINISHED = 0;
/** @var \Symfony\Component\HttpFoundation\Request */
protected $request;
// Payment class info
public static $name = 'NoName';
public static bool $canAutoReturn = false;
public $class = 'NoClass';
protected $databaseName;
// Shop Settings
public $config;
public $step = 0;
public $template = 'payment.tpl';
protected $templateCart;
protected $templateDescription;
protected $templateOrderView = 'payment.orderView.tpl';
protected $templateInit;
protected ?string $defaultIcon = null;
/** Order of payment.
* @var Order
*/
public $order;
public $orderId;
public $status = self::STATUS_UNKNOWN;
public ?int $paymentId = null;
protected $id;
protected $custom_data;
/**
* @var \KupShop\OrderingBundle\Exception\PaymentException
*/
public $exception;
protected $pay_method;
public const METHOD_CASH = 1;
public const METHOD_CARD = 2;
public const METHOD_INVOICE = 3;
public const METHOD_TRANSFER = 6;
public const METHOD_COD = 7;
public const METHOD_ONLINE = 8;
public const METHOD_CASH_INSERTION = 4;
public const METHOD_CASH_SELECTION = 5;
public const METHOD_COMPENSATION = 9;
// Používá se pro slovenské EET - úhrada faktury (101 - hotově, 102 - kartou)
public const METHOD_EET_INVOICE_CASH = 101;
public const METHOD_EET_INVOICE_CARD = 102;
public const METHOD_UNKNOWN = 10;
public const METHOD_INSTALLMENTS = 11;
protected $isNotification = false;
/** @var RestrictionUtils */
protected $restrictionUtils;
/** @var RestrictionParams */
protected $restrictionParams;
/** @var LoggerInterface */
protected $kibanaLogger;
public static function getSettingsConfiguration(): array
{
return [];
}
public static function getCanAutoReturn(): bool
{
return static::$canAutoReturn;
}
public static function includeClass($name)
{
global $cfg;
$name = preg_replace('/[^a-zA-Z0-9-]/', '', $name);
$class = "payments/class.{$name}.php";
if (file_exists($cfg['Path']['web_root'].$class) && !isFunctionalTests()) {
require_once $cfg['Path']['web_root'].$class;
return;
}
if (!file_exists($cfg['Path']['shared_class'].$class)) {
throw new NotFoundHttpException('Platební modul '.$name.' neexistuje');
}
require_once $cfg['Path']['shared_class'].$class;
}
/** Factory function for Payment clases.
* @param string $className Payment class name
*
* @return Payment
*/
public static function getClass($className)
{
self::includeClass($className);
if ($paymentClass = ServiceContainer::getService('kupshop.payment.'.strtolower($className), Container::NULL_ON_INVALID_REFERENCE)) {
return $paymentClass;
}
if (!$className::isEnabled($className)) {
return null;
}
return new $className();
}
/** List all Payment implementations.
*/
public static function listClasses()
{
global $cfg;
$classDir = $cfg['Path']['shared_class'].'payments/';
$shopClassDir = $cfg['Path']['web_root'].'payments/';
$classesDir = array_merge(glob($classDir.'class.*.php'), glob($shopClassDir.'class.*.php'));
$classes = [];
foreach ($classesDir as $class) {
$class = basename($class);
preg_match('/class.([a-zA-Z0-9-]+).php/', $class, $matches);
$class = $matches[1];
self::includeClass($class);
if (!$class::isEnabled($class)) {
continue;
}
$classes[$class] = $class::$name;
}
return $classes;
}
public function getId(): ?int
{
return $this->id ? (int) $this->id : null;
}
public function setIsNotification(bool $isNotification): Payment
{
$this->isNotification = $isNotification;
return $this;
}
public function setDatabaseName($databaseName)
{
$this->databaseName = $databaseName;
}
public function hasCartTitle()
{
return (bool) $this->templateCart;
}
public function hasCartTemplate()
{
return (bool) $this->templateCart;
}
public function getGenericPaymentUrl(int $step = 1, array $params = []): string
{
return path('kupshop_ordering_payment_payment', array_merge([
'IDo' => $this->order->id,
'cf' => $this->order->getSecurityCode(),
'step' => $step,
'class' => $this->class,
], $params), \Symfony\Component\Routing\Router::ABSOLUTE_URL);
}
/** @deprecated Use getGenericPaymentUrl() instead. */
public function getPaymentUrl()
{
return $this->getGenericPaymentUrl();
}
/** Get row in cart with payment title.
* @param Smarty $smarty
*
* @return string|null
*
* @internal param array $context
*/
public function getCartTitle($smarty)
{
if ($this->templateCart) {
$params = [
'object' => $this,
];
$_smarty_tpl_vars = $smarty->tpl_vars;
$ret = $smarty->_subTemplateRender($this->templateCart, $smarty->cache_id, $smarty->compile_id, 0, null, $params, 0, false);
$smarty->tpl_vars = $_smarty_tpl_vars;
return $ret;
}
return null;
}
public function getInitTemplate($smarty)
{
if ($this->templateInit) {
$params = [
'object' => $this,
];
$_smarty_tpl_vars = $smarty->tpl_vars;
$ret = $smarty->_subTemplateRender($this->templateInit, $smarty->cache_id, $smarty->compile_id, 0, null, $params, 0, false);
$smarty->tpl_vars = $_smarty_tpl_vars;
return $ret;
}
return null;
}
/** Get payment HTML when payment selected.
*/
public function getCartDescription()
{
if ($this->templateDescription) {
$smarty = createSmarty();
$smarty->assign([
'object' => $this,
]);
return $smarty->fetch($this->templateDescription);
}
return null;
}
/** Get payment HTML on completed order.
*/
public function getOrderViewDescription($smarty = null)
{
$params = [
'payment' => $this,
'order' => $this->order,
];
if ($smarty) {
return $smarty->_subTemplateRender($this->templateOrderView, $smarty->cache_id, $smarty->compile_id, 0, null, $params, 0, false);
} else {
$smarty = createSmarty(false, true);
$smarty->assign($params);
return $smarty->fetch($this->templateOrderView);
}
}
public static function isEnabled($className)
{
return true;
}
/** Get payment icon url and name.
* @return array|null
*/
public function getIcon()
{
return null;
}
protected function getRestrictionUtils()
{
if (!isset($this->restrictionUtils)) {
$this->restrictionUtils = ServiceContainer::getService(RestrictionUtils::class);
}
return $this->restrictionUtils->setType('payment');
}
public function accept($totalPrice, $freeDelivery)
{
return $this->getRestrictionUtils()->checkHardRequirements($this);
}
public function check(CartBase $cart)
{
$this->checkRestrictions($cart->getPurchaseState());
ServiceContainer::getService('event_dispatcher')->dispatch(
new \KupShop\OrderingBundle\Event\PaymentCheckEvent($this, $cart)
);
}
public function checkRestrictions(PurchaseState $purchaseState)
{
$customData = $this->getCustomData();
if ($restrictions = $customData['restrictions'] ?? false) {
if (!isset($this->restrictionParams)) {
$this->restrictionParams = $this->getRestrictionUtils()
->createParamsFromConfig($this->config ?? [], $customData['restrictions'] ?? []);
}
$this->getRestrictionUtils()->checkSoftRequirements(
$purchaseState->getDeliveryRestrictionParams(),
$this->restrictionParams
);
if ($productsFilter = $restrictions['productsFilter'] ?? null) {
$this->getRestrictionUtils()->checkProductsFilter($productsFilter, $purchaseState->getProductList());
}
}
}
public function setException(Exception $exception)
{
$this->exception = $exception;
}
public function getName()
{
return empty($this->databaseName) ? static::$name : $this->databaseName;
}
public function __construct()
{
$this->loadConfig();
$this->kibanaLogger = ServiceContainer::getService('logger');
}
public function __toString()
{
return static::class;
}
public function setOrder($order)
{
if (is_object($order)) {
$this->orderId = $order->id;
$this->order = $order;
} else {
$this->orderId = $order;
$this->order = new Order();
if (!$this->order->createFromDB($this->orderId)) {
$this->error(replacePlaceholders(translate('errorOrderNotFound', 'payment'), ['ID' => $this->orderId]));
}
}
return $this->order;
}
public function getOrderNumber(): string
{
return $this->order->order_no;
}
public function loadConfig(?string $language = null)
{
$cfg = Config::get();
// pokud je poslana $language, tak naloaduju settingy pro ten danej jazyk
// napr. v administraci getDefault vzdycky naloaduje default jazyk, ale v nekterych pripadech
// chci settingy pro jiny jazyk (napr. kdyz vracim platbu)
$dbcfg = $language ? Settings::getFromCache($language) : Settings::getDefault();
$dbConfig = $this->loadPaymentDbConfig($dbcfg);
if (isset($dbcfg->payment_config['order_status_new']) && !isset($dbConfig['order_status_new'])) {
$dbConfig['order_status_new'] = $dbcfg->payment_config['order_status_new'];
}
if (isset($dbcfg->payment_config['order_status_finished'])) {
$dbConfig['order_status_finished'] = $dbcfg->payment_config['order_status_finished'] !== 'keep' ? $dbcfg->payment_config['order_status_finished'] : null;
}
$config = $cfg['Modules']['payments'][$this->class] ?? false;
if (!is_array($config)) {
$config = [];
}
// Do not load database configuration in development - use one from config_db.php
if (isLocalDevelopment()) {
$dbConfig = [];
}
$this->config = array_merge($config, $dbConfig);
// neni pouzity isset kvuli tomu ze hodnota muze bejt null !!
if (!array_key_exists('order_status_finished', $this->config)) {
$this->config['order_status_finished'] = 1;
$languageContext = Contexts::get(LanguageContext::class);
if ($languageContext->translationActive()) {
// hotfix, zahranicni platby menily stav objednavek
$defaultDbcfg = Settings::getFromCache($languageContext->getDefaultId());
if (isset($defaultDbcfg->payment_config['order_status_finished'])) {
$this->config['order_status_finished'] = $defaultDbcfg->payment_config['order_status_finished'] !== 'keep' ? $defaultDbcfg->payment_config['order_status_finished'] : null;
}
}
}
// load domain config
if (array_key_exists('domain_config', $this->config)) {
$domainContext = ServiceContainer::getService(DomainContext::class);
$domain = $domainContext->getActiveId();
if (array_key_exists($domain, $this->config['domain_config'])) {
$domain_config = $this->config['domain_config'][$domain];
$this->config = array_merge($this->config, $domain_config);
}
}
}
protected function loadPaymentDbConfig($dbcfg): array
{
return array_filter($dbcfg->payments[$this->class] ?? []);
}
public function getOrderId($session)
{
$session = sqlFormatInput($session);
$orderId = returnSQLResult('SELECT id_order FROM '.getTableName('order_payments')." WHERE payment_data LIKE '%\"{$session}\"%'");
/*
if (empty($orderId))
logError(__FILE__, __LINE__, "Payment: getOrderId: Cannot get order id of session: {$session}, POST:".print_r($_POST, true));
*/
return $orderId;
}
public function getStatus($session = null)
{
$SQL = sqlQuery('SELECT id, status, payment_data FROM '.getTableName('order_payments')." WHERE id_order={$this->orderId} FOR UPDATE");
if (empty($session) && sqlNumRows($SQL) > 1) {
logError(__FILE__, __LINE__, 'Payment: getStatus: empty session but multiple payments!');
}
while (($payment = sqlFetchAssoc($SQL)) !== false) {
if (!empty($payment['payment_data'])) {
$payment_data = json_decode($payment['payment_data'], true);
if (empty($session) || (isset($payment_data['session']) && $payment_data['session'] == $session)) {
$this->status = $payment['status'];
$this->paymentId = $payment['id'];
return true;
}
}
}
return false;
}
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' => $this->pay_method ?? self::METHOD_ONLINE,
'admin' => getAdminID(null),
];
$this->insertSQL('order_payments', $fields);
$this->status = self::STATUS_CREATED;
$this->paymentId = sqlInsertId();
$this->order->updatePayments();
return true;
}
public function setStatus($status, $session = null)
{
$change = false;
// Použiju transakci, abych zajistil že zjištění stavu transakce a její následná změna jsou atomický a nevběhne tam mezitím jiný thread
sqlGetConnection()->transactional(function () use ($status, $session, &$change) {
if (!$this->getStatus($session)) {
logError(__FILE__, __LINE__, 'Payment::setStatus: get status failed!');
$this->error('Payment::setStatus: get status failed!');
}
if ($this->status != $status) {
if ($this->status == self::STATUS_FINISHED) {
return;
}
$change = true;
sqlQuery('UPDATE '.getTableName('order_payments')." SET status={$status}, date=NOW() WHERE id={$this->paymentId}");
}
});
// Tohle mám mimo transakci, protože to může trvat dlouho (odesílá se email o zaplacení).
// Hlavní je, že se tdvakrát nezavolá změna stavu zaplacení platby
if ($change) {
$this->order->updatePayments();
$forceEmail = null;
$this->changeOrderStatus($status, $forceEmail);
}
return true;
}
public function changeOrderStatus($paymentStatus, $forceEmail)
{
switch ($paymentStatus) {
case self::STATUS_FINISHED:
if ($this->order->isActive() && !$this->order->isPaid()) {
break;
}
$status = $this->config['order_status_finished'] ?? null;
if (is_null($status)) {
$status = $this->order->status;
}
$this->order->changeStatus($status, translate('msgStatusFinished', 'payment'), false);
if (($forceEmail !== false) && ($this->order->source != OrderInfo::ORDER_SOURCE_POS)) {
// Send Payment email
$this->order->sendPaymentReceipt($this->paymentId);
}
break;
}
$this->status = $paymentStatus;
}
private function getPayment()
{
return sqlQueryBuilder()->select('*')
->from('order_payments', 'op')
->where(\Query\Operator::equals(['op.id' => $this->paymentId]))->execute()->fetch();
}
public function checkOrderIsActiveAndNotPaid()
{
if (!$this->order->isActive()) {
$this->error(replacePlaceholders(translate('errorOrderCanceled', 'payment'), ['ID' => $this->order->order_no]));
}
if ($this->order->isPaid(true)) {
$this->error(replacePlaceholders(translate('errorOrderAlreadyPaid', 'payment'), ['ID' => $this->order->order_no]));
}
}
public function startPayment()
{
$this->step(1, translate('msgStartPayment', 'payment'));
}
public function processStep($index)
{
try {
if ($index == 1) {
$this->checkOrderIsActiveAndNotPaid();
}
if ($index >= 0) {
$reflectionMethod = new ReflectionMethod($this, 'processStep_'.intval($index));
return $reflectionMethod->invoke($this);
}
} catch (\KupShop\KupShopBundle\Exception\RedirectException $e) {
throw $e;
} catch (Exception $e) {
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_COMMUNICATION, 'Payment error: '.$this->getName().': '.$e->getMessage());
return $this->error($e->getMessage());
}
}
public function step($index, $message, $data = [])
{
// logError(__FILE__, __LINE__, "Payment::Step: $index, $message, {$this->orderId}");
if ($this->isNotification) {
$this->sendNotificationResponse(500, $message);
}
$redirect = [
'URL' => 'launch.php',
's' => 'payment',
'IDo' => $this->orderId,
'class' => $this->class,
'step' => $index,
'cf' => $this->order ? $this->order->getSecurityCode() : '',
'message' => $message,
];
$redirect = array_merge($redirect, $data);
redirection(createScriptURL($redirect));
}
public function getStepUrl(int $step): string
{
return createScriptURL([
's' => 'payment',
'IDo' => $this->order->id,
'cf' => $this->order->getSecurityCode(),
'step' => $step,
'class' => $this->class,
'absolute' => true,
]);
}
public function error($message)
{
if ($this->isNotification) {
$this->sendNotificationResponse(500, $message);
}
if ($this->order) {
addUserMessage($message, 'danger');
redirection($this->order->getUrl());
}
if (getVal('step') != -1) {
$this->step(-1, $message);
}
return false;
}
public function success($message)
{
if ($this->isNotification) {
$this->sendNotificationResponse(200, 'OK');
}
if ($this->order) {
addUserMessage($message, 'success');
redirection($this->order->getDetailUrl(1));
}
$this->step(-2, $message);
}
public function info($message)
{
if ($this->isNotification) {
$this->sendNotificationResponse(200, $message);
}
addUserMessage($message, 'info');
redirection($this->order->getUrl());
}
public function checkAuth()
{
$cf = getVal('cf');
if ($this->order->getSecurityCode() != $cf) {
throw new NotFoundHttpException(translate('errorSecurityCode', 'payment'));
}
}
public function storePaymentInfo()
{
return [];
}
public function loadPaymentInfo($data)
{
foreach ($data as $key => $value) {
$this->$key = $value;
}
}
public function requiresEET()
{
return false;
}
public function hasOnlinePayment()
{
return false;
}
public function hasPaymentDescription()
{
return $this->hasOnlinePayment();
}
public function getPayMethod()
{
if (!$this->pay_method) {
throw new RuntimeException('Missing pay_method in Payment class '.$this->getName());
} else {
return $this->pay_method;
}
}
/**
* Implements ArrayAccess interface.
*/
public function offsetSet($offset, $value): void
{
$this->{$offset} = $value;
}
public function offsetExists($offset): bool
{
return isset($this->{$offset});
}
public function offsetUnset($offset): void
{
unset($this->{$offset});
}
public function offsetGet($offset): mixed
{
return isset($this->{$offset}) ? $this->{$offset} : null;
}
/**
* @return false|array $payment (with decoded json payment_data as decoded_data)
*/
public function getPendingPayment()
{
$payments = $this->order->getPaymentsArray();
foreach ($payments as $payment) {
$paymentData = json_decode($payment['payment_data']);
if (($payment['status'] == self::STATUS_CREATED || $payment['status'] == self::STATUS_PENDING)
and isset($paymentData->paymentClass)
and $paymentData->paymentClass === static::class
) {
$payment['decoded_data'] = $paymentData;
return $payment;
}
}
return false;
}
public function setRequest(Symfony\Component\HttpFoundation\Request $request): Payment
{
$this->request = $request;
return $this;
}
public function orderCreatedPostProcess(Order $order)
{
if (($this->hasOnlinePayment() || (findModule(\Modules::BANK_AUTO_PAYMENTS) && $this->getPayMethod() === self::METHOD_TRANSFER))
&& !empty($this->config['order_status_new'])
&& $order->status !== $this->config['order_status_new']
) {
$order->changeStatus($this->config['order_status_new'], null, false);
}
}
public function deletePayment()
{
return sqlQueryBuilder()->delete('order_payments')->where(\Query\Operator::equals(['id' => $this->paymentId]))->execute();
}
public function processAdminWindowData($data)
{
return $data;
}
public function getCustomData()
{
if (isset($this->custom_data)) {
return $this->custom_data;
}
if ($this->id !== null) {
$this->custom_data = json_decode(sqlQueryBuilder()->select('data')
->from('delivery_type_payment')
->where(\Query\Operator::equals(['id' => $this->id]))
->execute()
->fetchColumn(), true);
}
return $this->custom_data;
}
/**
* @param int|null $id
*/
public function setID($id = null)
{
$this->id = $id;
}
/**
* @param null $custom_data
*/
public function setCustomData($custom_data): void
{
$this->custom_data = $custom_data;
}
private function isPaymentInstanceOfClass($payment)
{
$data = (is_array($payment['payment_data'])) ? $payment['payment_data'] : json_decode($payment['payment_data'], true);
return ($data['paymentClass'] ?? '') === static::class;
}
protected function updateReturnPayment($payment, $returnId, $full)
{
if ($returnId) {
$title = 'Vrácení částky: objednávka '.$this->order->order_no.', číslo platby: '.$payment['id'];
$data = ['return_from_payment' => $payment['id'], 'paymentClass' => $this->class];
$this->updateSQL('order_payments', ['note' => $title, 'payment_data' => json_encode($data)], ['id' => $returnId]);
}
}
public function getPaymentForReturn($amount, ?int $paymentId = null)
{
if ($paymentId) {
$this->paymentId = $paymentId;
$payment = $this->getPayment();
if ($this->isPaymentInstanceOfClass($payment)) {
$payment['payment_data'] = json_decode($payment['payment_data'], true);
return $payment;
}
}
$paymentsArray = $this->order->getPaymentsArray();
$payment = false;
foreach ($paymentsArray as $paytmp) {
if ((int) $paytmp['status'] === Payment::STATUS_FINISHED && $paytmp['price'] >= ($amount * -1)) {
$paytmp['payment_data'] = json_decode($paytmp['payment_data'], true);
if (isset($paytmp['payment_data']['session']) && $this->isPaymentInstanceOfClass($paytmp)) {
$payment = $paytmp;
}
}
}
if (!$payment) {
throw new PaymentException('Platba nemohla být automaticky vrácena, protože neexistuje validní platba.');
}
return $payment;
}
public function enabledReturnPayment(): bool
{
$dbcfg = \Settings::getDefault();
return (($dbcfg->payment_config['return_payment_auto_gate'] ?? null) !== 'N') && static::$canAutoReturn;
}
/**
* @throws PaymentException
*/
public function doReturnPayment(array $payment, float $amount)
{
return false;
}
/**
* @throws PaymentException
*/
public function returnPayment($amount, ?int $paymentId = null, ?int $returnId = null)
{
// TODO: zabránit znovuvrácení platby, když už se z ní vracelo - vracet pouze zbytek
// TODO: zakázat editaci vrácené platby, pokud byla vrácena automatem
if ($this->enabledReturnPayment()) {
$payment = $this->getPaymentForReturn($amount, $paymentId);
$this->updateReturnPayment($payment, $returnId, ($payment['price'] + $amount) == 0);
if ($result = $this->doReturnPayment($payment, $amount)) {
return $result;
}
}
$currencyContext = Contexts::get(CurrencyContext::class);
$currency = $currencyContext->getActive();
// Automatic return not implemented
throw new PaymentException('Vrácení nelze pro tento způsob platby automatizovat. Vraťte zákazníkovi '.$amount * (-1).' '.$currency->getSymbol().' manuálně.');
}
protected function sendNotificationResponse(int $httpResponseCode, $message)
{
http_response_code($httpResponseCode);
echo $message;
exit;
}
public function getPhoto(?string $photo, ?string $date_updated = null): ?array
{
$icon = $this->getPhotoPath($photo);
if ($icon === null) {
return null;
}
return getImage($this->id, basename($icon), dirname($icon), 8, $this::$name, strtotime($date_updated));
}
public function getPhotoPath(?string $photo): ?string
{
return empty($photo) ? $this->defaultIcon : '../payment/'.$photo;
}
public function getClassName(): string
{
return $this->class;
}
/**
* Pro platebni brany - vrati platebni metody.
*
* @return array|null
*/
public function getAvailableMethods()
{
return null;
}
public function getSelectedMethodId(): ?string
{
return $this->method ?? null;
}
public function getSelectedMethod(): ?array
{
if (empty($this->method)) {
return null;
}
$methods = $this->getAvailableMethods();
return $methods[$this->method] ?? null;
}
}