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