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

601 lines
22 KiB
PHP

<?php
use KupShop\AdminBundle\Util\ActivityLog;
use KupShop\KupShopBundle\Config;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\OrderingBundle\OrderList\OrderList;
use PayPal\Core\PayPalConstants;
use Query\Operator;
use Symfony\Component\Routing\Router;
/**
* Dependencies: `composer require paypal/rest-api-sdk-php=^1.14`
* Example config: $cfg['Modules']['payments']['PayPal'] = [
* 'clientID' => 'client id',
* 'secret' => 'client secret',
* webProfileID - create temporary (3 hours): symfony kupshop:paypal-create-web-profile
* or permanent profile: symfony kupshop:paypal-create-web-profile --permanent
* optional image path as an argument: symfony kupshop:paypal-create-web-profile --permanent templates/images/logo.png
* 'webProfileID' => 'web profile id',
* 'mode' => 'sandbox' OR 'live',
* 'enableLog' => false,
* ].
*/
class PayPal extends Payment
{
public static $name = 'PayPal platební brána';
public $template = 'payment.PayPal.tpl';
public $class = 'PayPal';
public $tp_id_payment;
public $method;
protected $pay_method = Payment::METHOD_ONLINE;
private $apiContext;
protected $allowedCurrencies = ['AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD',
'HUF', 'ILS', 'JPY', 'MYR', 'MXN', 'TWD', 'NZD', 'NOK', 'PHP', 'PLN', 'GBP',
'RUB', 'SGD', 'SEK', 'CHF', 'THB', 'USD', ];
public static function getSettingsConfiguration(): array
{
return [
'fields' => [
'clientID' => [
'title' => 'ID klienta (clientID)',
'type' => 'text',
],
'secret' => [
'title' => 'Secret',
'type' => 'text',
],
'mode' => [
'title' => 'Režim',
'type' => 'select',
'options' => ['live' => 'Produkční režim', 'sandbox' => 'Testovaci režim'],
],
'tracking' => [
'title' => 'Tracking info',
'type' => 'toggle',
'tooltip' => 'Odesílat číslo balíku z e-shopu k platbě do PayPalu.',
],
'webhook' => [
'title' => 'Webhook URL',
'text' => path('kupshop_ordering_payment_legacypayment', ['class' => 'PayPal', 'step' => 10], Router::ABSOLUTE_URL),
],
],
];
}
/* Payment steps */
public function processStep_1()
{
$apiContext = $this->getApiContext();
// always create new paypal payment in step 1
// $kupshopPayment = $this->getPendingPayment();
$kupshopPayment = false;
if ($kupshopPayment) {
// use already created payment
$payment = $this->getPayPalPayment($apiContext, $kupshopPayment['decoded_data']->session);
} else {
// create new payment
$dbcfg = Settings::getDefault();
if ($this->order->getRemainingPayment() <= 0) {
$this->success(translate('paymentSuccess', 'payment'));
}
$payerInfoAddress = new \PayPal\Api\Address();
$payerInfoAddress->setState($this->order->invoice_state ?? '')
->setCountryCode($this->order->invoice_country)
->setCity($this->order->invoice_city)
->setLine1($this->order->invoice_street)
->setLine2($this->order->invoice_custom_address ?? '')
->setPostalCode($this->order->invoice_zip);
$payerInfo = new \PayPal\Api\PayerInfo();
$payerInfo->setEmail($this->order->getUserEmail())
->setBillingAddress($payerInfoAddress)
->setFirstName($this->order->invoice_name)
->setLastName($this->order->invoice_surname);
$payer = new \PayPal\Api\Payer();
$payer->setPaymentMethod('paypal')
->setPayerInfo($payerInfo);
if ($useOriginalCurrency = in_array($this->order->currency, $this->allowedCurrencies)) {
$totalPrice = $this->order->getRemainingPayment();
} else {
$totalPrice = $this->convertPriceToEUR(
(float) $this->order->getRemainingPayment()
);
}
// shipping address
$shippingAddress = new \PayPal\Api\ShippingAddress();
$shippingAddress->setState($this->order->delivery_state ?? '')
->setRecipientName(implode(' ', [$this->order->delivery_name, $this->order->delivery_surname]))
->setCountryCode($this->order->delivery_country)
->setCity($this->order->delivery_city)
->setLine1($this->order->delivery_street)
->setLine2($this->order->delivery_custom_address ?? '')
->setPostalCode($this->order->delivery_zip);
// Set item list
$item1 = new \PayPal\Api\Item();
$item1->setName('Payment for order '.$this->getOrderNumber().' on '.$dbcfg->shop_firm_name)
->setCurrency($useOriginalCurrency ? $this->order->currency : 'EUR')
->setQuantity(1)
->setPrice($totalPrice);
$itemList = new \PayPal\Api\ItemList();
$itemList->setItems([$item1])
->setShippingAddress($shippingAddress);
// to set payment details see https://developer.paypal.com/docs/api/quickstart/create-process-order/
$amount = new \PayPal\Api\Amount();
$amount->setCurrency($useOriginalCurrency ? $this->order->currency : 'EUR')
->setTotal($totalPrice);
$transaction = new \PayPal\Api\Transaction();
$transaction->setAmount($amount)
->setDescription('Payment for order '.$this->getOrderNumber().' on '.$dbcfg->shop_firm_name)
->setInvoiceNumber($this->getOrderNumber())
->setItemList($itemList);
$redirectUrls = new \PayPal\Api\RedirectUrls();
$redirectUrls->setReturnUrl(
createScriptURL(
[
's' => 'payment',
'IDo' => $this->order->id,
'cf' => $this->order->getSecurityCode(),
'step' => 2,
'class' => $this->class,
'absolute' => true,
]
)
)
->setCancelUrl(
createScriptURL(
[
's' => 'payment',
'IDo' => $this->order->id,
'cf' => $this->order->getSecurityCode(),
'step' => 4,
'class' => $this->class,
'absolute' => true,
]
)
);
$payment = new \PayPal\Api\Payment();
$payment->setIntent('sale')
->setPayer($payer)
->setRedirectUrls($redirectUrls)
->setTransactions([$transaction]);
if (isset($this->config['webProfileID'])) {
$payment->setExperienceProfileId($this->config['webProfileID']);
}
// nechci umoznit nastavit pri platbe dorucovaci adresu, protoze oni si ji pak nastavi a ocekavaji, ze jim
// to prijde na tu adresu co nastavili v PayPalu. Takze bysme museli tu adresu tahat zpatky do e-shopu...
// a jednodusi je jim tu zmenu neumoznit
// bohuzel to musim udelat takhle osklive, protoze ta knihovna tu optionu neumoznuje nastavit nejakym setterem
$payment->application_context = [
// If available, uses the merchant-provided shipping address, which the customer cannot change on the PayPal pages.
// If the merchant does not provide an address, the customer can enter the address on PayPal pages.
'shipping_preference' => 'SET_PROVIDED_ADDRESS',
];
try {
$payment->create($apiContext);
} catch (PayPal\Exception\PayPalConnectionException $e) {
$data = json_decode($e->getData(), true);
if (($data['name'] ?? false) === 'VALIDATION_ERROR') {
logError(__FILE__, __LINE__, 'PayPal exception: '.print_r($e->getData(), true));
$this->error(translate('PayPalConnectionException', 'paypal'));
} else {
throw $e;
}
}
// save payment
$transaction = $payment->getTransactions()[0];
if ($useOriginalCurrency) {
$paymentAmount = $transaction->getAmount()->getTotal();
} else {
$paymentAmount = $this->order->convertPriceFromEUR($transaction->getAmount()->getTotal());
}
$this->createPayment(
$payment->getId(),
$paymentAmount,
['paymentClass' => self::class]
);
}
// redirect user to PayPal for the payment approval
$approvalUrl = $payment->getApprovalLink();
redirection($approvalUrl);
}
/**
* Success return from paypal - execute payment.
*/
public function processStep_2()
{
logError(__FILE__, __LINE__, 'PayPal response: '.print_r([$_GET, $_POST, $_SERVER], true));
$apiContext = $this->getApiContext();
$payPalPaymentID = getVal('paymentId', null, false);
$this->processPayment($this->getPayPalPayment($apiContext, $payPalPaymentID), true);
}
/**
* Waiting for approval - current payment status is created.
*/
public function processStep_3()
{
$apiContext = $this->getApiContext();
$payPalPaymentID = getVal('paymentId', null, false);
if (!$payPalPaymentID) {
$kupshopPayment = $this->getPendingPayment();
if ($kupshopPayment) {
// use already created payment
$payPalPaymentID = $kupshopPayment['decoded_data']->session;
} else {
$this->step(-3, 'storno');
}
}
$this->processPayment($this->getPayPalPayment($apiContext, $payPalPaymentID));
}
/**
* Storno.
*/
public function processStep_4()
{
$apiContext = $this->getApiContext();
$payPalPaymentID = getVal('paymentId', null, false);
if (!$payPalPaymentID) {
$kupshopPayment = $this->getPendingPayment();
if ($kupshopPayment) {
// use already created payment
$payPalPaymentID = $kupshopPayment['decoded_data']->session;
} else {
$this->step(-3, 'storno');
}
}
$payment = $this->getPayPalPayment($apiContext, $payPalPaymentID);
$payment->setState('failed');
$this->processPayment($payment);
}
/**
* PayPal webhook handler - check for approved payment.
*/
public function processStep_10()
{
$logger = ServiceContainer::getService('logger');
$logger->notice('PayPal: webhook', [
'data' => $this->request->getContent(),
]);
$this->isNotification = true;
$data = json_decode($this->request->getContent(), true);
// only process PAYMENT.SALE.COMPLETED and payment must already exist
if ($data['event_type'] !== 'PAYMENT.SALE.COMPLETED'
|| empty($data['resource']['parent_payment'])
) {
return;
}
$apiContext = $this->getApiContext();
$payPalPaymentID = $data['resource']['parent_payment'];
$payment = $this->getPayPalPayment($apiContext, $payPalPaymentID, false);
if (!$payment) {
return;
}
if (!($orderId = $this->getOrderId($payPalPaymentID))) {
$this->sendNotificationResponse(400, 'Order not found!');
}
// set order - getStatus method needs it
$this->setOrder($orderId);
// only proceed if payment already exists
if (!$this->getStatus($payPalPaymentID)) {
return;
}
$this->processPayment(
$payment,
false,
false
);
$this->sendNotificationResponse(200, 'OK');
}
/**
* Process payment procedure
* (used in step 2 - after return from PayPal, step 3 - waiting for approval, step 4 - storno and step 10 - webhook).
*
* @throws Exception
*/
protected function processPayment(
PayPal\Api\Payment $payment,
bool $enableRedirectToStep3 = false,
bool $enableRedirects = true,
) {
$this->tp_id_payment = $payment->getId();
$paymentState = $payment->getState();
if ($paymentState === 'created') {
$apiContext = $this->getApiContext();
// Execute payment with payer id
$execution = new \PayPal\Api\PaymentExecution();
$execution->setPayerId($payment->getPayer()->getPayerInfo()->getPayerId());
try {
// Execute payment
$payment->execute($execution, $apiContext);
} catch (Exception $ex) {
logError(__FILE__, __LINE__, 'PayPal exception: '.$ex->getMessage());
if ($ex instanceof \PayPal\Exception\PayPalConnectionException) {
logError(__FILE__, __LINE__, 'PayPal exception: '.print_r($ex->getData(), true));
}
$this->step(-3, 'storno');
}
}
// determine kupshop unified payment state from paypal payment state
switch ($paymentState) {
case 'created':
$this->status = $unifiedState = Payment::STATUS_CREATED;
break;
case 'approved':
$this->status = $unifiedState = Payment::STATUS_FINISHED;
break;
case 'failed':
$this->status = $unifiedState = Payment::STATUS_STORNO;
break;
default:
logError(__FILE__, __LINE__, 'PayPal\Api\Payment invalid state "'.$paymentState.'"');
if ($enableRedirects) {
$this->step(-3, 'storno');
}
return;
}
// change payment status
if (!$this->setStatus($unifiedState, $this->tp_id_payment)) {
logError(__FILE__, __LINE__, 'PayPal::updatePaymentStatus: setStatus failed!');
throw new \Exception('Set status failed');
}
if ($enableRedirects) {
switch ($unifiedState) {
case Payment::STATUS_FINISHED:
if ($sale = $payment->getTransactions()[0]->getRelatedResources()[0]->getSale()) {
if ($transactionID = $sale->getId()) {
$payment_data = $this->selectSQL('order_payments', ['id' => $this->paymentId], ['payment_data'])->fetchColumn();
$payment_data = json_decode($payment_data, true);
$payment_data['transactionID'] = $transactionID;
$this->updateSQL('order_payments', ['payment_data' => json_encode($payment_data)], ['id' => $this->paymentId]);
}
}
$this->success(translate('paymentSuccess', 'payment'));
break;
case Payment::STATUS_STORNO:
$this->step(-3, 'storno');
break;
case Payment::STATUS_CREATED:
if ($enableRedirectToStep3) {
$this->step(3, 'wait', ['paymentId' => $payment->getId()]);
}
break;
}
}
}
/**
* @return false|\PayPal\Api\Payment
*/
protected function getPayPalPayment(
PayPal\Rest\ApiContext $apiContext,
$payPalPaymentID,
bool $enableRedirects = true,
) {
try {
$payment = \PayPal\Api\Payment::get($payPalPaymentID, $apiContext);
} catch (Exception $ex) {
logError(__FILE__, __LINE__, 'PayPal\Api\Payment::get exception');
if ($enableRedirects) {
$this->step(-3, 'storno');
}
return false;
}
return $payment;
}
/**
* @return \PayPal\Rest\ApiContext
*/
public function getApiContext()
{
if (!isset($this->apiContext)) {
$apiContext = new PayPal\Rest\ApiContext(
new \PayPal\Auth\OAuthTokenCredential($this->config['clientID'], $this->config['secret'])
);
$apiConfig = [
'mode' => isset($this->config['mode']) ? $this->config['mode'] : 'sandbox',
'cache.enabled' => true,
// 'http.CURLOPT_CONNECTTIMEOUT' => 30
// 'http.headers.PayPal-Partner-Attribution-Id' => '123123123'
// 'log.AdapterFactory' => '\PayPal\Log\DefaultLogFactory' // Factory class implementing \PayPal\Log\PayPalLogFactory
];
if (isset($this->config['enableLog']) && $this->config['enableLog']) {
$apiConfig['log.LogEnabled'] = true;
$apiConfig['log.FileName'] = './PayPal.log';
$apiConfig['log.LogLevel'] = ($apiConfig['mode'] == 'sandbox') ? 'DEBUG' : 'INFO'; // PLEASE USE `INFO` LEVEL FOR LOGGING IN LIVE ENVIRONMENTS
}
$apiContext->setConfig($apiConfig);
$this->apiContext = $apiContext;
}
return $this->apiContext;
}
public function hasOnlinePayment()
{
return true;
}
public static function isEnabled($className)
{
$cfg = Config::get();
if (empty($cfg['Modules']['payments'][$className])) {
return false;
}
return true;
}
public function sendOrderTrackings(): void
{
if (($this->config['tracking'] ?? 0) != 1) {
return;
}
$orderList = ServiceContainer::getService(OrderList::class);
$orderList->andSpec(function (Query\QueryBuilder $qb) {
$qb->join('o', 'delivery_type', 'dt', 'dt.id = o.id_delivery')
->join('dt', 'delivery_type_payment', 'dtp_paypal_filter', 'dtp_paypal_filter.id = dt.id_payment');
return Operator::andX(
// musi byt vyplnene tracking cislo
'o.package_id IS NOT NULL',
// filtruju objednavky, u kterych nebyl tracking jeste odeslany
'JSON_VALUE(o.note_admin, "$.paypalTrackingSent") IS NULL',
// filtruju pouze objednavky s PayPal platbou
Operator::equals(['dtp_paypal_filter.class' => $this->class]),
// beru v potaz objednavky za max. posledni tyden
'o.date_handle IS NOT NULL AND o.date_handle >= DATE(NOW() - INTERVAL 7 DAY)',
// objednavka musi byt vyrizena
Operator::inIntArray(getStatuses('handled'), 'o.status'),
// objednavka musi byt zaplacena
Operator::equals(['o.status_payed' => 1]),
// objednavka nesmi byt osobni odber
Operator::not(\Query\Order::byInPersonDelivery())
);
});
foreach ($orderList->getOrders() as $order) {
$this->sendOrderTrackingToPayPal($order);
}
}
public function sendOrderTrackingToPayPal(Order $order): bool
{
$apiContext = $this->getApiContext();
$token = $apiContext->getCredential()->getAccessToken($apiContext->getConfig());
$url = $this->config['mode'] === 'sandbox' ? PayPalConstants::REST_SANDBOX_ENDPOINT : PayPalConstants::REST_LIVE_ENDPOINT;
[$carrier, $carrierOtherName] = $this->getOrderCarrier($order);
$orderData = [
'status' => 'SHIPPED',
'carrier' => $carrier,
'carrier_other_name' => $carrierOtherName,
'last_update_time' => $order->date_handle->format('c'),
'tracking_number' => $order->package_id,
];
$trackers = [];
$payments = array_filter($order->getPaymentsArray(), fn ($x) => $x['status'] == Payment::STATUS_FINISHED);
foreach ($payments as $payment) {
$paymentData = json_decode($payment['payment_data'] ?: '', true) ?: [];
if (empty($paymentData['transactionID'])) {
throw new RuntimeException('PayPal payment is missing "transactionID"! Order: '.$order->order_no);
}
$trackers[] = array_merge($orderData, ['transaction_id' => $paymentData['transactionID']]);
}
if (empty($trackers)) {
return false;
}
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url.'v1/shipping/trackers-batch');
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer '.$token,
]);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode(['trackers' => $trackers]));
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($curl);
$responseCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
$result = json_decode($response ?: '', true) ?: [];
if (!in_array($responseCode, [200, 201])) {
addActivityLog(
ActivityLog::SEVERITY_ERROR,
ActivityLog::TYPE_COMMUNICATION,
'[PayPal] Nepodařila se odeslat informace o číslu balíku! Objednávka: '.$order->order_no,
['errors' => $result['errors'] ?? []]);
return false;
}
$order->setData('paypalTrackingSent', true);
$order->logHistory('[PayPal] Byla odeslána informace o číslu balíku z e-shopu do PayPalu.');
return true;
}
protected function getOrderCarrier(Order $order): array
{
$delivery = null;
if ($deliveryType = $order->getDeliveryType()) {
$delivery = $deliveryType->getDelivery();
}
if ($delivery instanceof PPL) {
return ['PPL', null];
} elseif ($delivery instanceof DHL) {
return ['DHL', null];
} elseif ($delivery instanceof BalikDoRuky) {
return ['CESKA_CZ', null];
} elseif ($delivery instanceof GLS && $order->delivery_country === 'CZ') {
return ['GLS_CZ', null];
}
return ['OTHER', $delivery->name];
}
}