View source
<?php
namespace Drupal\commerce_paypal\Plugin\Commerce\PaymentGateway;
use Drupal\commerce_paypal\Event\ExpressCheckoutRequestEvent;
use Drupal\commerce_paypal\Event\PayPalEvents;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_payment\Entity\PaymentInterface;
use Drupal\commerce_payment\Exception\PaymentGatewayException;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayBase;
use Drupal\commerce_price\Price;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class ExpressCheckout extends OffsitePaymentGatewayBase implements ExpressCheckoutInterface {
const SHIPPING_ASK_ALWAYS = 'shipping_ask_always';
const SHIPPING_ASK_NOT_PRESENT = 'shipping_ask_not_present';
const SHIPPING_SKIP = 'shipping_skip';
protected $logger;
protected $httpClient;
protected $rounder;
protected $time;
protected $ipnHandler;
protected $moduleHandler;
protected $eventDispatcher;
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
$instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
$instance->logger = $container
->get('logger.channel.commerce_paypal');
$instance->httpClient = $container
->get('http_client');
$instance->rounder = $container
->get('commerce_price.rounder');
$instance->ipnHandler = $container
->get('commerce_paypal.ipn_handler');
$instance->moduleHandler = $container
->get('module_handler');
$instance->eventDispatcher = $container
->get('event_dispatcher');
return $instance;
}
public function defaultConfiguration() {
return [
'api_username' => '',
'api_password' => '',
'shipping_prompt' => self::SHIPPING_SKIP,
'signature' => '',
'solution_type' => 'Mark',
] + parent::defaultConfiguration();
}
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form = parent::buildConfigurationForm($form, $form_state);
$form['api_username'] = [
'#type' => 'textfield',
'#title' => $this
->t('API Username'),
'#default_value' => $this->configuration['api_username'],
'#required' => TRUE,
];
$form['api_password'] = [
'#type' => 'textfield',
'#title' => $this
->t('API Password'),
'#default_value' => $this->configuration['api_password'],
'#required' => TRUE,
];
$form['signature'] = [
'#type' => 'textfield',
'#title' => $this
->t('Signature'),
'#default_value' => $this->configuration['signature'],
'#required' => TRUE,
];
$form['solution_type'] = [
'#type' => 'radios',
'#title' => $this
->t('Type of checkout flow'),
'#description' => $this
->t('Express Checkout Account Optional (ECAO) where PayPal accounts are not required for payment may not be available in all markets.'),
'#options' => [
'Mark' => $this
->t('Require a PayPal account (this is the standard configuration).'),
'SoleLogin' => $this
->t('Allow PayPal AND credit card payments, defaulting to the PayPal form.'),
'SoleBilling' => $this
->t('Allow PayPal AND credit card payments, defaulting to the credit card form.'),
],
'#default_value' => $this->configuration['solution_type'],
];
$form['shipping_prompt'] = [
'#type' => 'radios',
'#title' => $this
->t('Shipping address collection'),
'#description' => $this
->t('Express Checkout will only request a shipping address if the Shipping module is enabled to store the address in the order.'),
'#options' => [
self::SHIPPING_SKIP => $this
->t('Do not ask for a shipping address at PayPal.'),
],
'#default_value' => $this->configuration['shipping_prompt'],
];
if ($this->moduleHandler
->moduleExists('commerce_shipping')) {
$form['shipping_prompt']['#options'] += [
self::SHIPPING_ASK_NOT_PRESENT => $this
->t('Ask for a shipping address at PayPal if the order does not have one yet.'),
self::SHIPPING_ASK_ALWAYS => $this
->t('Ask for a shipping address at PayPal even if the order already has one.'),
];
}
return $form;
}
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::validateConfigurationForm($form, $form_state);
if (!$form_state
->getErrors() && $form_state
->isSubmitted()) {
$values = $form_state
->getValue($form['#parents']);
$this->configuration['api_username'] = $values['api_username'];
$this->configuration['api_password'] = $values['api_password'];
$this->configuration['signature'] = $values['signature'];
$this->configuration['solution_type'] = $values['solution_type'];
$this->configuration['mode'] = $values['mode'];
$response = $this
->doRequest([
'METHOD' => 'GetBalance',
]);
if ($response['ACK'] != 'Success') {
$form_state
->setError($form, $this
->t('Invalid API credentials.'));
}
}
}
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
parent::submitConfigurationForm($form, $form_state);
if (!$form_state
->getErrors()) {
$values = $form_state
->getValue($form['#parents']);
$this->configuration['api_username'] = $values['api_username'];
$this->configuration['api_password'] = $values['api_password'];
$this->configuration['signature'] = $values['signature'];
$this->configuration['solution_type'] = $values['solution_type'];
$this->configuration['shipping_prompt'] = $values['shipping_prompt'];
}
}
public function onReturn(OrderInterface $order, Request $request) {
$order_express_checkout_data = $order
->getData('paypal_express_checkout');
if (empty($order_express_checkout_data['token'])) {
throw new PaymentGatewayException('Token data missing for this PayPal Express Checkout transaction.');
}
$paypal_response = $this
->getExpressCheckoutDetails($order);
if ($paypal_response['ACK'] == 'Failure') {
throw new PaymentGatewayException($paypal_response['PAYMENTREQUESTINFO_0_LONGMESSAGE'], $paypal_response['PAYMENTREQUESTINFO_n_ERRORCODE']);
}
$order_express_checkout_data['payerid'] = $paypal_response['PAYERID'];
$order
->setData('paypal_express_checkout', $order_express_checkout_data);
if (empty($order->mail)) {
$order
->setEmail($paypal_response['EMAIL']);
}
$paypal_response = $this
->doExpressCheckoutDetails($order);
if (isset($paypal_response['PAYMENTINFO_0_PAYMENTSTATUS']) && $paypal_response['PAYMENTINFO_0_PAYMENTSTATUS'] == 'Failed') {
throw new PaymentGatewayException($paypal_response['PAYMENTINFO_0_LONGMESSAGE'], $paypal_response['PAYMENTINFO_0_ERRORCODE']);
}
if ($paypal_response['ACK'] == 'Failure') {
if (isset($paypal_response['L_ERRORCODE0']) && $paypal_response['L_ERRORCODE0'] == "10486") {
$message = $paypal_response['L_LONGMESSAGE0'];
throw new PaymentGatewayException("{$message} Express Checkout payment failed due to a bad funding source; it is possible that the transaction exceeded the buyer's card limit.", $paypal_response['L_ERRORCODE0']);
}
throw new PaymentGatewayException($paypal_response['L_LONGMESSAGE0'], $paypal_response['L_ERRORCODE0']);
}
$payment_storage = $this->entityTypeManager
->getStorage('commerce_payment');
$payment = $payment_storage
->create([
'state' => 'authorization',
'amount' => $order
->getTotalPrice(),
'payment_gateway' => $this->entityId,
'order_id' => $order
->id(),
'remote_id' => $paypal_response['PAYMENTINFO_0_TRANSACTIONID'],
'remote_state' => $paypal_response['PAYMENTINFO_0_PAYMENTSTATUS'],
]);
$status_mapping = $this
->getStatusMapping();
if (isset($status_mapping[$paypal_response['PAYMENTINFO_0_PAYMENTSTATUS']])) {
$payment
->setState($status_mapping[$paypal_response['PAYMENTINFO_0_PAYMENTSTATUS']]);
}
$payment
->save();
}
public function capturePayment(PaymentInterface $payment, Price $amount = NULL) {
$this
->assertPaymentState($payment, [
'authorization',
]);
$amount = $amount ?: $payment
->getAmount();
$amount = $this->rounder
->round($amount);
$paypal_response = $this
->doCapture($payment, $amount
->getNumber());
if ($paypal_response['ACK'] == 'Failure') {
$message = $paypal_response['L_LONGMESSAGE0'];
throw new PaymentGatewayException($message, $paypal_response['L_ERRORCODE0']);
}
$payment
->setState('completed');
$payment
->setAmount($amount);
$payment
->setRemoteId($paypal_response['TRANSACTIONID']);
$payment
->save();
}
public function voidPayment(PaymentInterface $payment) {
$this
->assertPaymentState($payment, [
'authorization',
]);
$paypal_response = $this
->doVoid($payment);
if ($paypal_response['ACK'] == 'Failure') {
$message = $paypal_response['L_LONGMESSAGE0'];
throw new PaymentGatewayException($message, $paypal_response['L_ERRORCODE0']);
}
$payment
->setState('authorization_voided');
$payment
->save();
}
public function refundPayment(PaymentInterface $payment, Price $amount = NULL) {
$this
->assertPaymentState($payment, [
'completed',
'partially_refunded',
]);
$amount = $amount ?: $payment
->getAmount();
$this
->assertRefundAmount($payment, $amount);
$amount = $this->rounder
->round($amount);
$extra['amount'] = $amount
->getNumber();
$old_refunded_amount = $payment
->getRefundedAmount();
$new_refunded_amount = $old_refunded_amount
->add($amount);
if ($new_refunded_amount
->lessThan($payment
->getAmount())) {
$payment
->setState('partially_refunded');
$extra['refund_type'] = 'Partial';
}
else {
$payment
->setState('refunded');
if ($amount
->lessThan($payment
->getAmount())) {
$extra['refund_type'] = 'Partial';
}
else {
$extra['refund_type'] = 'Full';
}
}
$paypal_response = $this
->doRefundTransaction($payment, $extra);
if ($paypal_response['ACK'] == 'Failure') {
$message = $paypal_response['L_LONGMESSAGE0'];
throw new PaymentGatewayException($message, $paypal_response['L_ERRORCODE0']);
}
$payment
->setRefundedAmount($new_refunded_amount);
$payment
->save();
}
public function onNotify(Request $request) {
$ipn_data = $this->ipnHandler
->process($request);
if (empty($ipn_data['txn_id'])) {
$this->logger
->alert('The IPN request does not have a transaction id. Ignored.');
return FALSE;
}
if (!in_array($ipn_data['payment_status'], [
'Voided',
'Pending',
'Completed',
'Refunded',
])) {
throw new BadRequestHttpException('Invalid payment status');
}
$payment_storage = $this->entityTypeManager
->getStorage('commerce_payment');
$amount = new Price($ipn_data['mc_gross'], $ipn_data['mc_currency']);
if (in_array($ipn_data['payment_status'], [
'Voided',
'Pending',
'Completed',
]) && !empty($ipn_data['auth_id'])) {
$payment = $payment_storage
->loadByRemoteId($ipn_data['auth_id']);
if (!$payment) {
$this->logger
->warning('IPN for Order @order_number ignored: authorization transaction already created.', [
'@order_number' => $ipn_data['invoice'],
]);
return FALSE;
}
$payment
->setAmount($amount);
$payment
->setState($this
->getStatusMapping($ipn_data['payment_status']));
$payment
->setRemoteId($ipn_data['txn_id']);
}
elseif ($ipn_data['payment_status'] == 'Refunded') {
$payment = $payment_storage
->loadByRemoteId($ipn_data['txn_id']);
if (!$payment) {
$this->logger
->warning('IPN for Order @order_number ignored: the transaction to be refunded does not exist.', [
'@order_number' => $ipn_data['invoice'],
]);
return FALSE;
}
elseif ($payment
->getState() == 'refunded') {
$this->logger
->warning('IPN for Order @order_number ignored: the transaction is already refunded.', [
'@order_number' => $ipn_data['invoice'],
]);
return FALSE;
}
$amount = new Price((string) $ipn_data['mc_gross'], $ipn_data['mc_currency']);
$old_refunded_amount = $payment
->getRefundedAmount();
$new_refunded_amount = $old_refunded_amount
->add($amount);
if ($new_refunded_amount
->lessThan($payment
->getAmount())) {
$payment
->setState('partially_refunded');
}
else {
$payment
->setState('refunded');
}
$payment
->setRefundedAmount($new_refunded_amount);
}
if (isset($payment)) {
$payment
->setRemoteState($ipn_data['payment_status']);
$payment
->save();
}
}
public function getRedirectUrl() {
if ($this
->getMode() == 'test') {
return 'https://www.sandbox.paypal.com/checkoutnow';
}
else {
return 'https://www.paypal.com/checkoutnow';
}
}
public function getApiUrl() {
if ($this
->getMode() == 'test') {
return 'https://api-3t.sandbox.paypal.com/nvp';
}
else {
return 'https://api-3t.paypal.com/nvp';
}
}
public function setExpressCheckout(PaymentInterface $payment, array $extra) {
$order = $payment
->getOrder();
$amount = $this->rounder
->round($payment
->getAmount());
$configuration = $this
->getConfiguration();
if ($extra['capture']) {
$payment_action = 'Sale';
}
else {
$payment_action = 'Authorization';
}
$nvp_data = [
'METHOD' => 'SetExpressCheckout',
'SOLUTIONTYPE' => 'Mark',
'LANDINGPAGE' => 'Login',
'ALLOWNOTE' => '0',
'PAYMENTREQUEST_0_PAYMENTACTION' => $payment_action,
'PAYMENTREQUEST_0_AMT' => $amount
->getNumber(),
'PAYMENTREQUEST_0_CURRENCYCODE' => $amount
->getCurrencyCode(),
'PAYMENTREQUEST_0_INVNUM' => $order
->id() . '-' . $this->time
->getCurrentTime(),
'RETURNURL' => $extra['return_url'],
'CANCELURL' => $extra['cancel_url'],
];
if (!empty($configuration['reference_transactions']) && !empty($configuration['ba_desc'])) {
$nvp_data['BILLINGTYPE'] = 'MerchantInitiatedBillingSingleAgreement';
$nvp_data['L_BILLINGTYPE0'] = 'MerchantInitiatedBillingSingleAgreement';
$nvp_data['L_BILLINGAGREEMENTDESCRIPTION0'] = $configuration['ba_desc'];
}
if ($configuration['solution_type'] != 'Mark') {
$nvp_data['SOLUTIONTYPE'] = 'Sole';
if ($configuration['solution_type'] == 'SoleBilling') {
$nvp_data['LANDINGPAGE'] = 'Billing';
}
}
$nvp_data += $this
->itemizeOrder($order, $amount
->getCurrencyCode());
if ($configuration['shipping_prompt'] == self::SHIPPING_SKIP || !$this->moduleHandler
->moduleExists('commerce_shipping')) {
$nvp_data['NOSHIPPING'] = '1';
}
else {
if ($order
->hasField('shipments') && !$order
->get('shipments')
->isEmpty()) {
$shipping_profiles = [];
foreach ($order
->get('shipments')
->referencedEntities() as $shipment) {
if ($shipment
->get('shipping_profile')
->isEmpty()) {
continue;
}
$shipping_profile = $shipment
->getShippingProfile();
$shipping_profiles[$shipping_profile
->id()] = $shipping_profile;
}
if ($shipping_profiles && count($shipping_profiles) === 1) {
$shipping_profile = reset($shipping_profiles);
$address = $shipping_profile->address
->first();
$name = $address
->getGivenName() . ' ' . $address
->getFamilyName();
$shipping_info = [
'PAYMENTREQUEST_0_SHIPTONAME' => substr($name, 0, 32),
'PAYMENTREQUEST_0_SHIPTOSTREET' => substr($address
->getAddressLine1(), 0, 100),
'PAYMENTREQUEST_0_SHIPTOSTREET2' => substr($address
->getAddressLine2(), 0, 100),
'PAYMENTREQUEST_0_SHIPTOCITY' => substr($address
->getLocality(), 0, 40),
'PAYMENTREQUEST_0_SHIPTOSTATE' => substr($address
->getAdministrativeArea(), 0, 40),
'PAYMENTREQUEST_0_SHIPTOCOUNTRYCODE' => $address
->getCountryCode(),
'PAYMENTREQUEST_0_SHIPTOZIP' => substr($address
->getPostalCode(), 0, 20),
];
$nvp_data += array_filter($shipping_info);
if ($configuration['shipping_prompt'] != self::SHIPPING_ASK_ALWAYS) {
$nvp_data += [
'NOSHIPPING' => '1',
'ADDROVERRIDE' => '1',
];
}
else {
$nvp_data += [
'NOSHIPPING' => '0',
'ADDROVERRIDE' => '0',
];
}
}
else {
$nvp_data['NOSHIPPING'] = '0';
}
}
}
if (!empty($order
->getEmail())) {
$nvp_data['PAYMENTREQUEST_0_EMAIL'] = $order
->getEmail();
}
return $this
->doRequest($nvp_data, $order);
}
protected function itemizeOrder(OrderInterface $order, $currency_code) {
$nvp_data = [];
$n = 0;
$items_total = new Price('0', $currency_code);
foreach ($order
->getItems() as $item) {
$item_amount = $this->rounder
->round($item
->getUnitPrice());
$nvp_data += [
'L_PAYMENTREQUEST_0_NAME' . $n => $item
->getTitle(),
'L_PAYMENTREQUEST_0_AMT' . $n => $item_amount
->getNumber(),
'L_PAYMENTREQUEST_0_QTY' . $n => $item
->getQuantity(),
];
$items_total = $items_total
->add($item
->getTotalPrice());
$n++;
}
$shipping_amount = new Price('0', $currency_code);
$tax_amount = new Price('0', $currency_code);
$adjustments = [];
foreach ($order
->collectAdjustments() as $adjustment) {
if ($adjustment
->isIncluded()) {
continue;
}
if ($adjustment
->getType() == 'shipping') {
$shipping_amount = $shipping_amount
->add($adjustment
->getAmount());
}
elseif ($adjustment
->getType() == 'tax') {
$tax_amount = $tax_amount
->add($adjustment
->getAmount());
}
else {
$type = $adjustment
->getType();
$source_id = $adjustment
->getSourceId();
if (empty($source_id)) {
$key = count($adjustments);
}
else {
$key = $type . '_' . $source_id;
}
if (empty($adjustments[$key])) {
$adjustments[$key] = [
'type' => $type,
'label' => (string) $adjustment
->getLabel(),
'total' => $adjustment
->getAmount(),
];
}
else {
$adjustments[$key]['total'] = $adjustments[$key]['total']
->add($adjustment
->getAmount());
}
}
}
foreach ($adjustments as $adjustment) {
$adjustment_amount = $this->rounder
->round($adjustment['total']);
$nvp_data += [
'L_PAYMENTREQUEST_0_NAME' . $n => $adjustment['label'],
'L_PAYMENTREQUEST_0_AMT' . $n => $adjustment_amount
->getNumber(),
'L_PAYMENTREQUEST_0_QTY' . $n => 1,
];
$items_total = $items_total
->add($adjustment['total']);
$n++;
}
$items_total = $this->rounder
->round($items_total);
$nvp_data['PAYMENTREQUEST_0_ITEMAMT'] = $items_total
->getNumber();
if (!$shipping_amount
->isZero()) {
$shipping_amount = $this->rounder
->round($shipping_amount);
$nvp_data['PAYMENTREQUEST_0_SHIPPINGAMT'] = $shipping_amount
->getNumber();
}
if (!$tax_amount
->isZero()) {
$tax_amount = $this->rounder
->round($tax_amount);
$nvp_data['PAYMENTREQUEST_0_TAXAMT'] = $tax_amount
->getNumber();
}
return $nvp_data;
}
public function getExpressCheckoutDetails(OrderInterface $order) {
$order_express_checkout_data = $order
->getData('paypal_express_checkout');
$nvp_data = [
'METHOD' => 'GetExpressCheckoutDetails',
'TOKEN' => $order_express_checkout_data['token'],
];
return $this
->doRequest($nvp_data, $order);
}
public function doExpressCheckoutDetails(OrderInterface $order) {
$order_express_checkout_data = $order
->getData('paypal_express_checkout');
$amount = $this->rounder
->round($order
->getTotalPrice());
if ($order_express_checkout_data['capture']) {
$payment_action = 'Sale';
}
else {
$payment_action = 'Authorization';
}
$nvp_data = [
'METHOD' => 'DoExpressCheckoutPayment',
'TOKEN' => $order_express_checkout_data['token'],
'PAYMENTREQUEST_0_AMT' => $amount
->getNumber(),
'PAYMENTREQUEST_0_CURRENCYCODE' => $amount
->getCurrencyCode(),
'PAYMENTREQUEST_0_INVNUM' => $order
->getOrderNumber(),
'PAYERID' => $order_express_checkout_data['payerid'],
'PAYMENTREQUEST_0_PAYMENTACTION' => $payment_action,
];
return $this
->doRequest($nvp_data, $order);
}
public function doCapture(PaymentInterface $payment, $amount) {
$order = $payment
->getOrder();
$nvp_data = [
'METHOD' => 'DoCapture',
'AUTHORIZATIONID' => $payment
->getRemoteId(),
'AMT' => $amount,
'CURRENCYCODE' => $payment
->getAmount()
->getCurrencyCode(),
'INVNUM' => $order
->getOrderNumber(),
'COMPLETETYPE' => 'Complete',
];
return $this
->doRequest($nvp_data, $payment
->getOrder());
}
public function doVoid(PaymentInterface $payment) {
$nvp_data = [
'METHOD' => 'DoVoid',
'AUTHORIZATIONID' => $payment
->getRemoteId(),
];
return $this
->doRequest($nvp_data, $payment
->getOrder());
}
public function doRefundTransaction(PaymentInterface $payment, array $extra) {
$nvp_data = [
'METHOD' => 'RefundTransaction',
'TRANSACTIONID' => $payment
->getRemoteId(),
'REFUNDTYPE' => $extra['refund_type'],
'AMT' => $extra['amount'],
'CURRENCYCODE' => $payment
->getAmount()
->getCurrencyCode(),
];
return $this
->doRequest($nvp_data, $payment
->getOrder());
}
public function doRequest(array $nvp_data, OrderInterface $order = NULL) {
$configuration = $this
->getConfiguration();
$nvp_data += [
'USER' => $configuration['api_username'],
'PWD' => $configuration['api_password'],
'SIGNATURE' => $configuration['signature'],
'VERSION' => '124.0',
];
$event = new ExpressCheckoutRequestEvent($nvp_data, $order);
$this->eventDispatcher
->dispatch(PayPalEvents::EXPRESS_CHECKOUT_REQUEST, $event);
$nvp_data = $event
->getNvpData();
$request = $this->httpClient
->post($this
->getApiUrl(), [
'form_params' => $nvp_data,
])
->getBody()
->getContents();
parse_str(html_entity_decode($request), $paypal_response);
return $paypal_response;
}
protected function getStatusMapping($status = NULL) {
$mapping = [
'Voided' => 'authorization_voided',
'Pending' => 'authorization',
'Processed' => 'completed',
'Completed' => 'completed',
'Refunded' => 'refunded',
'Partially-Refunded' => 'partially_refunded',
'Expired' => 'authorization_expired',
];
if (isset($status) && isset($mapping[$status])) {
return $mapping[$status];
}
return $mapping;
}
}