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

597 lines
22 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\EANValidator;
use KupShop\OrderingBundle\Exception\PaymentException;
use KupShop\OrderingBundle\Util\Order\PaymentOrderSubMethodTrait;
/**
* !!!! POZOR !!!!
* Pokud se nasazuje na betu testovací GoPay, je potřeba mít v configu test => 1, aby to na nasazené betě fungovalo.
* Jinak v košíku neuvidíte žádné jejich platby - gopay odmítne spojení ostrého gopay serveru s testovacími údaji.
* A Nedivit se, na lokale ať je cokoliv nastavené, přetluče to config_db, kde jsou testovací údaje z KOZA. *
* !!!! POZOR !!!!
*/
class GoPay extends Payment
{
use PaymentOrderSubMethodTrait;
public const BAD_STATE_CODE = '303';
public static $name = 'GoPay platební brána';
public static bool $canAutoReturn = true;
public $template = 'payment.GoPay.tpl';
protected $templateCart = 'payment.GoPay.cart.tpl';
protected $templateOrderView = 'payment.GoPay.orderView.tpl';
public $class = 'GoPay';
public $method;
protected $pay_method = Payment::METHOD_ONLINE;
private $apiContext;
private $gatewayUrl;
public static function getSettingsConfiguration(): array
{
return [
'fields' => [
'goid' => [
'title' => 'GoID',
'type' => 'text',
],
'clientId' => [
'title' => 'ClientID',
'type' => 'text',
],
'clientSecret' => [
'title' => 'ClientSecret',
'type' => 'text',
],
'test' => [
'title' => 'Testovací režim',
'type' => 'toggle',
],
],
];
}
/** 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 getPaymentUrl(int $step = 1): string
{
return path('kupshop_ordering_payment_payment', [
'IDo' => $this->order->id,
'cf' => $this->order->getSecurityCode(),
'step' => $step,
'class' => $this->class,
], \Symfony\Component\Routing\Router::ABSOLUTE_URL);
}
public function getGatewayUrl(): string
{
if (!isset($this->gatewayUrl)) {
$this->processStep_1(false, false);
}
return $this->gatewayUrl;
}
/* Payment steps */
public function processStep_1($enableWaitRedirect = true, $enableRedirects = true)
{
$apiContext = $this->getApiContext();
if (empty($this->method)) {
$paymentData = $this->order->getData('payment_data');
if (!$paymentData) {
$paymentData = ['method' => 'PAYMENT_CARD'];
}
$this->loadPaymentInfo($paymentData);
}
$tmpMethod = explode('__', $this->method);
$defaultPaymentInstrument = $tmpMethod[0];
$defaultSwift = isset($tmpMethod[1]) ? $tmpMethod[1] : null;
$availableInstruments = [];
$availableSwifts = [];
// test gopay (maybe live too?) might return swifts that triggers error 111
// 'BACXCZPP', 'AGBACZPP', 'AIRACZPP', 'EQBKCZPP', 'CITICZPX', 'INGBCZPP', 'EXPNCZPP', 'OBKLCZ2X', 'VBOECZ2X', 'SUBACZPP'
// maybe check against $usableSwifts = array_values(
// (new ReflectionClass(\GoPay\Definition\Payment\BankSwiftCode::class))
// ->getConstants()
// );
foreach ($this->getAvailableMethods() as $methodID => $method) {
$tmpMethod = explode('__', $methodID);
if (!in_array($tmpMethod[0], $availableInstruments)) {
$availableInstruments[] = $tmpMethod[0];
}
if (isset($tmpMethod[1]) && !in_array($tmpMethod[1], $availableSwifts)) {
$availableSwifts[] = $tmpMethod[1];
}
}
$rawPaymentRequest = [
'payer' => [
'default_payment_instrument' => $defaultPaymentInstrument,
'default_swift' => $defaultSwift,
'contact' => [
'first_name' => $this->order->invoice_name,
'last_name' => $this->order->invoice_surname,
'email' => $this->order->invoice_email,
'phone_number' => $this->order->invoice_phone,
'city' => $this->order->invoice_city,
'street' => $this->order->invoice_street,
'postal_code' => $this->order->invoice_zip,
'country_code' => (new \League\ISO3166\ISO3166())->alpha2(
!empty($this->order->invoice_country)
? $this->order->invoice_country
: (!empty($this->order->delivery_country) ? $this->order->delivery_country : 'CZ')
)['alpha3'],
],
],
'amount' => $amount = toDecimal($this->order->getRemainingPayment())->mul(DecimalConstants::hundred())->asFloat(),
'currency' => $this->order->getCurrency(),
'order_number' => $this->getOrderNumber(),
'items' => [],
'callback' => [
'return_url' => $this->getPaymentUrl(2),
'notification_url' => $this->getPaymentUrl(10),
],
];
foreach ($this->order->fetchItems() as $item) {
$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'];
}
if (isset($item['product'])) {
$tmpItem['product_url'] = createScriptURL([
'absolute' => true,
's' => 'product',
'IDproduct' => $item['id_product'],
'TITLE' => $item['product']->title,
]).(!empty($item['id_variation']) ? '#'.$item['id_variation'] : '');
}
$rawPaymentRequest['items'][] = $tmpItem;
}
$response = $apiContext->createPayment($rawPaymentRequest);
if ($response->hasSucceed()) {
$this->createPayment(
$response->json['id'],
Decimal::fromInteger($response->json['amount'])
->mul(Decimal::fromFloat(0.01))->asFloat(),
['paymentClass' => self::class]
);
$this->gatewayUrl = $response->json['gw_url'];
} else {
$jsonErrors = json_decode($response->rawBody, true);
$errorMessage = [];
foreach ($jsonErrors['errors'] ?? [] as $error) {
$errorMessage[] = $error['message'];
}
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_COMMUNICATION,
'GoPay chyba: '.implode(', ', $errorMessage).' Objednávka '.$this->order->order_no,
['order' => "Kód objednávky {$this->order->order_no} (ID: {$this->order->id})",
'request' => json_encode($rawPaymentRequest),
'response' => (string) $response]);
$this->step(-3, 'storno');
}
}
/**
* GoPay return from gateway.
*/
public function processStep_2()
{
$this->checkPaymentStatus();
}
/**
* Waiting for approval - current payment status is created.
*/
public function processStep_3()
{
$this->checkPaymentStatus(false);
$this->info(translate('payment_waiting_for_confirmation', 'payment'));
}
/**
* Change GoPay payment method.
*/
public function processStep_4()
{
$kupshopPayment = $this->getPendingPayment();
if ($kupshopPayment) {
// storno pending payment so new one can be created
$this->setStatus(Payment::STATUS_STORNO, $kupshopPayment['decoded_data']->session);
}
$this->step(1, '');
}
/**
* GoPay webhook handler.
*/
public function processStep_10()
{
$this->setIsNotification(true);
$this->checkPaymentStatus();
$this->sendNotificationResponse(200, 'OK');
}
private function checkPaymentStatus(
$enableWaitRedirect = true,
$goPayPaymentID = false,
$enableStepOne = false,
) {
if (!$goPayPaymentID) {
$goPayPaymentID = getVal('id', null, false);
}
if (!$goPayPaymentID) {
$this->error(translate('payment_id_missing', 'payment'));
}
$response = $this->getApiContext()->getStatus($goPayPaymentID);
if (!$response->hasSucceed()) {
$this->error(translate('payment_status_check_error', 'payment'));
return;
}
$goPayPaymentState = $response->json['state'];
// determine kupshop unified payment state from GoPay payment state
if (in_array($goPayPaymentState, [
'CREATED', 'AUTHORIZED',
])
) {
$this->status = $unifiedState = Payment::STATUS_CREATED;
if ($enableStepOne) {
$this->gatewayUrl = $response->json['gw_url'];
$this->step = 1;
return;
}
} elseif ($goPayPaymentState === 'PAYMENT_METHOD_CHOSEN') {
$this->status = $unifiedState = Payment::STATUS_PENDING;
// customer might have returned from the gateway without success/failure
// set gatewayUrl so it can be used in order detail
$this->gatewayUrl = $response->json['gw_url'];
} elseif (in_array($goPayPaymentState, ['PAID'])) {
$this->status = $unifiedState = Payment::STATUS_FINISHED;
} elseif (in_array($goPayPaymentState, [
'CANCELED', 'TIMEOUTED', 'REFUNDED', 'PARTIALLY_REFUNDED',
])
) {
$this->status = $unifiedState = Payment::STATUS_STORNO;
} else {
logError(__FILE__, __LINE__, 'GoPay unexpected payment state "'.$goPayPaymentState.'"');
$this->error(translate('payment_unexpected_status', 'payment'));
return;
}
// change payment status
if (!$this->setStatus($unifiedState, $goPayPaymentID)) {
logError(__FILE__, __LINE__, 'PayPal::updatePaymentStatus: setStatus failed!');
throw new \Exception('Set status failed');
}
$paymentInstrument = $response->json['payment_instrument'] ?? null;
if ($paymentInstrument && $paymentInstrument != ($response->json['payer']['default_payment_instrument'] ?? null)) {
$this->setPaymentSubMethod($paymentInstrument);
}
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:
if ($enableWaitRedirect) {
$this->step(3, 'wait', ['id' => $goPayPaymentID]);
}
break;
}
}
/**
* @return GoPay\Payments
*/
public function getApiContext()
{
if (!isset($this->apiContext)) {
if (isset($this->config['language'])) {
$language = $this->config['language'];
} else {
$languageContext = ServiceContainer::getService(LanguageContext::class);
// see \GoPay\Definition\Language
$availableLangs = [
'cs' => 'CS', 'en' => 'EN', 'sk' => 'SK', 'de' => 'DE', 'ru' => 'RU', 'pl' => 'PL',
'hu' => 'HU', 'fr' => 'FR', 'ro' => 'RO', 'bg' => 'BG', 'hr' => 'HR', 'it' => 'IT',
'es' => 'ES', 'at' => 'DE',
];
$language = $availableLangs[$languageContext->getActiveId()] ?? \GoPay\Definition\Language::CZECH;
}
$apiContext = GoPay\payments([
'goid' => $this->config['goid'],
'clientId' => $this->config['clientId'],
'clientSecret' => $this->config['clientSecret'],
'isProductionMode' => empty($this->config['test']),
'scope' => GoPay\Definition\TokenScope::ALL,
'language' => $language,
'timeout' => isset($this->config['timeout']) ? $this->config['timeout'] : 30,
'gatewayUrl' => empty($this->config['test']) ? 'https://gate.gopay.cz/api/' : 'https://gw.sandbox.gopay.com/api/',
]);
$this->apiContext = $apiContext;
}
return $this->apiContext;
}
public function getAvailableMethods()
{
$currencyContext = ServiceContainer::getService(CurrencyContext::class);
$currency = $currencyContext->getActiveId();
$domainContext = ServiceContainer::getService(DomainContext::class);
$domain = $domainContext->getActiveId();
$languageContext = ServiceContainer::getService(LanguageContext::class);
$language = $languageContext->getActiveId();
$cacheKey = "gopay-methods-{$currency}-{$domain}-{$language}";
if (!($methods = getCache($cacheKey))) {
$methods = $this->fetchAvailableMethods($currency);
setCache($cacheKey, $methods);
}
return $methods;
}
public function fetchAvailableMethods($currency)
{
/**
* Sračka GoPay vrací rychlý převody společně s offline platbama (obyč převod, kde se jen napíše číslo účtu a vs)
* To jestli je metoda rychlej převod nebo offline se rozlišuje přes "isOnline"
* My zobrazujeme na eshopu jen ty isOnline=true, protože jinak tam je 15 zbytečnejch metod úplně k prdu
* Metoda "Rychlý bankovní převod" je kec, jsou tam tedy i pomalý offline převody
* Když nejsou povolený offline převody, můžu "Rychlý bankovní převod" smazat, není k ničemu, protože online se zobrazí samostatně
* V Adminu to maj úplně dementní. Nedaj se vypínat jednotlivý metody, jen jestli je daná banka online nebo offline.
* Naprosto nepoužitelnou metody GoPay která nejde vypnout se pokouším hodit dozadu, stejně tak "Offline" bankovní převody, protože jsou k ničemu.
*/
$apiContext = $this->getApiContext();
$response = $apiContext->getPaymentInstruments($this->config['goid'], $currency);
if (!$response->hasSucceed()) {
return [];
}
$methods = [];
$offlinePayment = false;
foreach ((array) $response->json['enabledPaymentInstruments'] as $method) {
if (in_array($method['paymentInstrument'], $this->config['ignoreInstruments'] ?? [])) {
continue;
}
$methods[$method['paymentInstrument']] = [
'name' => translate_shop($method['paymentInstrument'], 'gopay', true) ?: $method['label']['cs'],
'id' => $method['paymentInstrument'],
'image' => $method['image']['large'],
];
if (empty($method['enabledSwifts']) || !is_array($method['enabledSwifts'])) {
continue;
}
foreach ($method['enabledSwifts'] as $swift) {
if ($currency === 'PLN' && $swift['swift'] === 'DNBANOKK') {
continue; // skip (GoPay did not accept this swift)
}
if (!$swift['isOnline']) {
$offlinePayment = $method['paymentInstrument'];
continue; // skip offline payments, show one method instead of 15
}
$methods[$method['paymentInstrument'].'__'.$swift['swift']] = [
'name' => $swift['label']['cs'],
'id' => $method['paymentInstrument'].'__'.$swift['swift'],
'image' => $swift['image']['large'],
];
}
if (!$offlinePayment) {
unset($methods[$method['paymentInstrument']]);
}
}
if ($offlinePayment) {
$method = $methods[$offlinePayment];
unset($methods[$offlinePayment]);
$methods[$offlinePayment] = $method;
}
if (isset($methods['GOPAY'])) {
$method = $methods['GOPAY'];
unset($methods['GOPAY']);
$methods['GOPAY'] = $method;
}
return $methods;
}
public function getEmbedScriptUrl()
{
return $this->getApiContext()->urlToEmbedJs();
}
public function hasOnlinePayment()
{
return true;
}
public static function isEnabled($className)
{
$cfg = Config::get();
if (empty($cfg['Modules']['payments'][$className])) {
return false;
}
return true;
}
/**
* @throws PaymentException
*/
public function doReturnPayment(array $payment, float $amount): array
{
$amountCents = (int) floor($amount * -100); // musí být integer v centech
$paymentId = $payment['payment_data']['session'];
$refundResponse = $this->getApiContext()->refundPayment($paymentId, $amountCents);
$this->kibanaLogger->notice('[GoPay] doReturnPayment response', [
'order_id' => $payment['id_order'],
'payment_id' => $paymentId,
'amountCents' => $amountCents,
'refund_response_status' => $refundResponse->statusCode,
'refund_response' => $refundResponse->__toString(),
]);
if (!$refundResponse->hasSucceed()) {
$message = translate('returnFailed', 'orderPayment');
$errors = $refundResponse->json['errors'] ?? [];
$error = reset($errors);
if ($error['error_code'] == self::BAD_STATE_CODE && $this->isPaymentRefunded($paymentId)) {
$result = [
'id' => $paymentId,
'result' => 'FINISHED',
];
}
if (!isset($result)) {
if (!empty($error['message'])) {
$message .= translate('returnFailedMessage', 'orderPayment').$error['message'];
}
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_COMMUNICATION, $message,
['amount' => $amount, 'payment_id' => $paymentId, 'id_order' => $payment['id_order'], 'RESPONSE' => $refundResponse]);
throw new PaymentException($message);
}
} else {
$result = (array) $refundResponse->json;
}
switch ($result['result']) {
case 'FAILED':
$message = translate('returnFailed', 'orderPayment');
addActivityLog(ActivityLog::SEVERITY_ERROR, ActivityLog::TYPE_COMMUNICATION, $message,
['amount' => $amount, 'payment_id' => $paymentId, 'id_order' => $payment['id_order'], 'RESPONSE' => $refundResponse]);
throw new PaymentException($message);
case 'ACCEPT':
case 'FINISHED':
return $result;
}
return $result;
}
/**
* Check paid orders (and update orders.status_payed).
*/
public function checkPaidOrders()
{
if (getVal('test', $this->config)) {
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'] !== 'GoPay' || empty($paymentData['session'])) {
continue;
}
// https://doc.gopay.com/cs/?lang=php#stav-platby
$response = $context->getStatus($paymentData['session']);
if (!$response->hasSucceed() || empty($response->json['state'])) {
continue;
}
if ($response->json['state'] === '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 ($response->json['state'] === 'CANCELED' || $response->json['state'] === 'TIMEOUTED') {
$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);
}
}
// print_r(['id' => $paymentData['session'], 'state' => $response->json['state']]);
}
return 0;
}
public function isPaymentRefunded($paymentId): bool
{
$statusResponse = $this->getApiContext()->getStatus($paymentId);
if (!$statusResponse->hasSucceed()) {
return false;
}
$status = $statusResponse->json;
return $status['state'] == 'REFUNDED';
}
}