Commit f9875d38 authored by mglaman's avatar mglaman Committed by bojanz

Issue #2785591: Initial implementation of an off-site API.

parent e4d6636f
......@@ -2,12 +2,14 @@
namespace Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow;
use Drupal\commerce\Response\NeedsRedirectException;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
......@@ -147,6 +149,22 @@ abstract class CheckoutFlowBase extends PluginBase implements CheckoutFlowInterf
return isset($step_ids[$current_index + 1]) ? $step_ids[$current_index + 1] : NULL;
}
/**
* {@inheritdoc}
*/
public function redirectToStep($step_id) {
$this->order->checkout_step = $step_id;
if ($step_id == 'complete') {
$transition = $this->order->getState()->getWorkflow()->getTransition('place');
$this->order->getState()->applyTransition($transition);
}
$this->order->save();
throw new NeedsRedirectException(Url::fromRoute('commerce_checkout.form', [
'commerce_order' => $this->order->id(),
'step' => $step_id,
])->toString());
}
/**
* {@inheritdoc}
*/
......@@ -373,11 +391,6 @@ abstract class CheckoutFlowBase extends PluginBase implements CheckoutFlowInterf
}
// Hide the actions element if it has no buttons.
$actions['#access'] = isset($actions['previous']) || isset($actions['next']);
// Once these two steps are reached, the user can't go back.
if (in_array($this->stepId, ['offsite_payment', 'complete'])) {
$actions['#access'] = FALSE;
}
return $actions;
}
......
......@@ -56,6 +56,16 @@ interface CheckoutFlowInterface extends FormInterface, ConfigurablePluginInterfa
*/
public function getNextStepId();
/**
* Redirects an order to a specific step in the checkout.
*
* @param string $step_id
* The step ID to redirect to.
*
* @throws \Drupal\commerce\Response\NeedsRedirectException
*/
public function redirectToStep($step_id);
/**
* Gets the defined steps.
*
......
......@@ -29,6 +29,7 @@ class MultistepDefault extends CheckoutFlowWithPanesBase {
'review' => [
'label' => $this->t('Review'),
'next_label' => $this->t('Continue to review'),
'previous_label' => $this->t('Cancel payment'),
'has_order_summary' => TRUE,
],
] + parent::getSteps();
......
......@@ -9,3 +9,12 @@ payment_method_icons:
css:
theme:
css/commerce_payment.payment_method_icons.css: {}
offsite_redirect:
version: VERSION
js:
js/offiste-redirect.js: {}
dependencies:
- core/jquery
- core/drupal
- core/drupalSettings
......@@ -62,3 +62,26 @@ entity.commerce_payment_method.collection:
parameters:
user:
type: entity:user
commerce_payment.checkout.cancel:
path: '/checkout/{commerce_order}/{step}/cancel'
defaults:
_controller: '\Drupal\commerce_payment\Controller\PaymentCheckoutController::cancelPaymentPage'
requirements:
_custom_access: '\Drupal\commerce_checkout\Controller\CheckoutController::checkAccess'
_module_dependencies: commerce_checkout
options:
parameters:
commerce_order:
type: entity:commerce_order
commerce_payment.checkout.return:
path: '/checkout/{commerce_order}/{step}/return'
defaults:
_controller: '\Drupal\commerce_payment\Controller\PaymentCheckoutController::returnPaymentPage'
requirements:
_custom_access: '\Drupal\commerce_checkout\Controller\CheckoutController::checkAccess'
_module_dependencies: commerce_checkout
options:
parameters:
commerce_order:
type: entity:commerce_order
/**
* @file
* Defines behaviors for the payment redirect form.
*/
(function ($, Drupal, drupalSettings) {
'use strict';
/**
* Attaches the commercePaymentRedirect behavior.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the commercePaymentRedirect behavior.
*/
Drupal.behaviors.commercePaymentRedirect = {
attach: function (context) {
$('.payment-redirect-form', context).find('input[type="submit"]').trigger('click');
}
};
})(jQuery, Drupal, drupalSettings);
<?php
namespace Drupal\commerce_payment\Controller;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayInterface;
use Drupal\Core\Access\AccessException;
class PaymentCheckoutController {
/**
* Controller callback for offsite payments cancelled.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $commerce_order
* The order.
*/
public function cancelPaymentPage(OrderInterface $commerce_order) {
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
$payment_gateway = $commerce_order->payment_gateway->entity;
$payment_gateway_plugin = $payment_gateway->getPlugin();
if (!$payment_gateway_plugin instanceof OffsitePaymentGatewayInterface) {
throw new AccessException('The payment gateway for the order does not implement ' . OffsitePaymentGatewayInterface::class);
}
$payment_gateway_plugin->onRedirectCancel($commerce_order);
/** @var \Drupal\commerce_checkout\Entity\CheckoutFlowInterface $checkout_flow */
$checkout_flow = $commerce_order->checkout_flow->entity;
$checkout_flow_plugin = $checkout_flow->getPlugin();
$checkout_flow_plugin->redirectToStep($checkout_flow_plugin->getPreviousStepId());
}
/**
* Controller callback for offsite payments returned.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $commerce_order
* The order.
*/
public function returnPaymentPage(OrderInterface $commerce_order) {
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
$payment_gateway = $commerce_order->payment_gateway->entity;
$payment_gateway_plugin = $payment_gateway->getPlugin();
if (!$payment_gateway_plugin instanceof OffsitePaymentGatewayInterface) {
throw new AccessException('The payment gateway for the order does not implement ' . OffsitePaymentGatewayInterface::class);
}
$payment_gateway_plugin->onRedirectReturn($commerce_order);
/** @var \Drupal\commerce_checkout\Entity\CheckoutFlowInterface $checkout_flow */
$checkout_flow = $commerce_order->checkout_flow->entity;
$checkout_flow_plugin = $checkout_flow->getPlugin();
$checkout_flow_plugin->redirectToStep($checkout_flow_plugin->getNextStepId());
}
}
......@@ -4,13 +4,16 @@ namespace Drupal\commerce_payment\Plugin\Commerce\CheckoutPane;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase;
use Drupal\commerce_payment\Entity\PaymentGatewayInterface as EntityPaymentGatewayInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsStoredPaymentMethodsInterface;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\profile\Entity\Profile;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
......@@ -80,10 +83,11 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
* {@inheritdoc}
*/
public function buildPaneSummary() {
$summary = '';
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
$payment_gateway = $this->order->payment_gateway->entity;
if (!$payment_gateway) {
return '';
return $summary;
}
$payment_gateway_plugin = $payment_gateway->getPlugin();
......@@ -95,11 +99,13 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
}
else {
$billing_profile = $this->order->getBillingProfile();
if ($billing_profile) {
$profile_view_builder = $this->entityTypeManager->getViewBuilder('profile');
$profile_view = $profile_view_builder->view($billing_profile, 'default');
$summary = $payment_gateway->getPlugin()->getDisplayLabel();
$summary .= $this->renderer->render($profile_view);
}
}
return $summary;
}
......@@ -110,8 +116,6 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
/** @var \Drupal\commerce_payment\PaymentGatewayStorageInterface $payment_gateway_storage */
$payment_gateway_storage = $this->entityTypeManager->getStorage('commerce_payment_gateway');
/** @var \Drupal\commerce_payment\PaymentMethodStorageInterface $payment_method_storage */
$payment_method_storage = $this->entityTypeManager->getStorage('commerce_payment_method');
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface[] $payment_gateways */
$payment_gateways = $payment_gateway_storage->loadMultipleForOrder($this->order);
// When no payment gateways are defined, throw an error and fail reliably.
......@@ -122,8 +126,57 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
// @todo Support multiple gateways.
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
$payment_gateway = reset($payment_gateways);
$pane_form['payment_gateway'] = [
'#type' => 'value',
'#value' => $payment_gateway->id(),
];
$payment_gateway_plugin = $payment_gateway->getPlugin();
if ($payment_gateway_plugin instanceof SupportsStoredPaymentMethodsInterface) {
$this->attachPaymentMethodForm($payment_gateway, $pane_form, $form_state);
}
else {
/** @var \Drupal\profile\Entity\ProfileInterface $billing_profile */
$billing_profile = $this->order->getBillingProfile();
if (!$billing_profile) {
$billing_profile = Profile::create([
'uid' => $this->order->getCustomerId(),
'type' => 'customer',
]);
}
$form_display = EntityFormDisplay::collectRenderDisplay($billing_profile, 'default');
$form_display->buildForm($billing_profile, $pane_form, $form_state);
// Remove the details wrapper from the address field.
if (!empty($pane_form['address']['widget'][0])) {
$pane_form['address']['widget'][0]['#type'] = 'container';
}
// Store the billing profile for the validate/submit methods.
$pane_form['#entity'] = $billing_profile;
}
return $pane_form;
}
/**
* Creates the payment method selection form for supported gateways.
*
* @param \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway
* The payment gateway.
* @param array $pane_form
* The pane form, containing the following basic properties:
* - #parents: Identifies the position of the pane form in the overall
* parent form, and identifies the location where the field values are
* placed within $form_state->getValues().
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state of the parent form.
*/
protected function attachPaymentMethodForm(EntityPaymentGatewayInterface $payment_gateway, array &$pane_form, FormStateInterface $form_state) {
/** @var \Drupal\commerce_payment\PaymentMethodStorageInterface $payment_method_storage */
$payment_method_storage = $this->entityTypeManager->getStorage('commerce_payment_method');
$payment_gateway_plugin = $payment_gateway->getPlugin();
$options = [];
$default_option = NULL;
$customer = $this->order->getCustomer();
......@@ -174,8 +227,6 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
'#default_value' => $payment_method,
];
}
return $pane_form;
}
/**
......@@ -192,16 +243,38 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
*/
public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
$values = $form_state->getValue($pane_form['#parents']);
/** @var \Drupal\commerce_payment\PaymentGatewayStorageInterface $payment_gateway_storage */
$payment_gateway_storage = $this->entityTypeManager->getStorage('commerce_payment_gateway');
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
$payment_gateway = $payment_gateway_storage->load($values['payment_gateway']);
if ($payment_gateway->getPlugin() instanceof SupportsStoredPaymentMethodsInterface) {
if (!isset($values['payment_method'])) {
$form_state->setError($complete_form, $this->noPaymentGatewayErrorMessage());
}
}
else {
/** @var \Drupal\profile\Entity\ProfileInterface $billing_profile */
$billing_profile = $pane_form['#entity'];
$form_display = EntityFormDisplay::collectRenderDisplay($billing_profile, 'default');
$form_display->extractFormValues($billing_profile, $pane_form, $form_state);
$form_display->validateFormValues($billing_profile, $pane_form, $form_state);
}
}
/**
* {@inheritdoc}
*/
public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
$values = $form_state->getValue($pane_form['#parents']);
/** @var \Drupal\commerce_payment\PaymentGatewayStorageInterface $payment_gateway_storage */
$payment_gateway_storage = $this->entityTypeManager->getStorage('commerce_payment_gateway');
/** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */
$payment_gateway = $payment_gateway_storage->load($values['payment_gateway']);
if ($payment_gateway->getPlugin() instanceof SupportsStoredPaymentMethodsInterface) {
if (is_numeric($values['payment_method'])) {
/** @var \Drupal\commerce_payment\PaymentMethodStorageInterface $payment_method_storage */
$payment_method_storage = $this->entityTypeManager->getStorage('commerce_payment_method');
......@@ -216,6 +289,17 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
$this->order->payment_method = $payment_method;
$this->order->setBillingProfile($payment_method->getBillingProfile());
}
else {
/** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
$this->order->payment_gateway = $payment_gateway;
/** @var \Drupal\profile\Entity\ProfileInterface $billing_profile */
$billing_profile = $pane_form['#entity'];
$form_display = EntityFormDisplay::collectRenderDisplay($billing_profile, 'default');
$form_display->extractFormValues($billing_profile, $pane_form, $form_state);
$billing_profile->save();
$this->order->setBillingProfile($billing_profile);
}
}
/**
* Returns an error message in case there are no payment gateways.
......
......@@ -2,15 +2,15 @@
namespace Drupal\commerce_payment\Plugin\Commerce\CheckoutPane;
use Drupal\commerce\Response\NeedsRedirectException;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase;
use Drupal\commerce_payment\Exception\PaymentGatewayException;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsiteRedirectPaymentGatewayInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OnsitePaymentGatewayInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
......@@ -133,31 +133,19 @@ class PaymentProcess extends CheckoutPaneBase implements ContainerFactoryPluginI
$payment_gateway = $this->order->payment_gateway->entity;
$payment_gateway_plugin = $payment_gateway->getPlugin();
if ($payment_gateway_plugin instanceof OnsitePaymentGatewayInterface) {
try {
$payment_storage = $this->entityTypeManager->getStorage('commerce_payment');
$payment = $payment_storage->create([
'state' => 'new',
'amount' => $this->order->getTotalPrice(),
'payment_gateway' => $payment_gateway->id(),
'payment_method' => $this->order->payment_method->entity,
'order_id' => $this->order->id(),
]);
$payment_gateway_plugin->createPayment($payment, $this->configuration['capture']);
$next_step_id = $this->checkoutFlow->getNextStepId();
// @todo Add a checkout flow method for completing checkout.
if ($next_step_id == 'complete') {
$transition = $this->order->getState()->getWorkflow()->getTransition('place');
$this->order->getState()->applyTransition($transition);
}
$this->order->checkout_step = $next_step_id;
$this->order->save();
throw new NeedsRedirectException(Url::fromRoute('commerce_checkout.form', [
'commerce_order' => $this->order->id(),
'step' => $next_step_id,
])->toString());
if ($payment_gateway_plugin instanceof OnsitePaymentGatewayInterface) {
try {
$payment->payment_method = $this->order->payment_method->entity;
$payment_gateway_plugin->createPayment($payment, $this->configuration['capture']);
$this->checkoutFlow->redirectToStep($this->checkoutFlow->getNextStepId());
}
catch (PaymentGatewayException $e) {
......@@ -165,6 +153,22 @@ class PaymentProcess extends CheckoutPaneBase implements ContainerFactoryPluginI
$this->redirectToPreviousStep();
}
}
elseif ($payment_gateway_plugin instanceof OffsitePaymentGatewayInterface) {
$pane_form['offsite_payment'] = [
'#type' => 'commerce_payment_gateway_form',
'#operation' => 'offsite-payment',
'#default_value' => $payment,
];
if ($payment_gateway_plugin instanceof OffsiteRedirectPaymentGatewayInterface) {
$redirect_url = $payment_gateway_plugin->getRedirectUrl();
// Make sure the redirect URL is the root form's action.
$complete_form['#action'] = $redirect_url;
$complete_form['#attributes']['class'][] = 'payment-redirect-form';
}
return $pane_form;
}
else {
drupal_set_message($this->t('Sorry, we can currently only support on site payment gateways.'), 'error');
$this->redirectToPreviousStep();
......@@ -183,13 +187,7 @@ class PaymentProcess extends CheckoutPaneBase implements ContainerFactoryPluginI
$previous_step_id = $pane->getStepId();
}
}
$this->order->checkout_step = $previous_step_id;
$this->order->save();
throw new NeedsRedirectException(Url::fromRoute('commerce_checkout.form', [
'commerce_order' => $this->order->id(),
'step' => $previous_step_id,
])->toString());
$this->checkoutFlow->redirectToStep($previous_step_id);
}
}
<?php
namespace Drupal\commerce_payment\Plugin\Commerce\PaymentGateway;
use Drupal\commerce_order\Entity\OrderInterface;
use Drupal\Core\Url;
/**
* Provides the base class for off-site payment gateways.
*/
abstract class OffsitePaymentGatewayBase extends PaymentGatewayBase implements OffsitePaymentGatewayInterface {
/**
* {@inheritdoc}
*/
public function getRedirectCancelUrl(OrderInterface $order) {
return Url::fromRoute('commerce_payment.checkout.cancel', [
'commerce_order' => $order->id(),
'step' => 'payment',
], ['absolute' => TRUE]);
}
/**
* {@inheritdoc}
*/
public function getRedirectReturnUrl(OrderInterface $order) {
return Url::fromRoute('commerce_payment.checkout.return', [
'commerce_order' => $order->id(),
'step' => 'payment',
], ['absolute' => TRUE]);
}
}
<?php
namespace Drupal\commerce_payment\Plugin\Commerce\PaymentGateway;
use Drupal\commerce_order\Entity\OrderInterface;
/**
* Defines the base interface for off-site payment gateways.
*/
interface OffsitePaymentGatewayInterface extends PaymentGatewayInterface {
/**
* Gets the URL for offiste redirect cancel.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
*
* @return \Drupal\Core\Url
* The Url object
*/
public function getRedirectCancelUrl(OrderInterface $order);
/**
* Gets the URL for offiste redirect return.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
*
* @return \Drupal\Core\Url
* The Url object
*/
public function getRedirectReturnUrl(OrderInterface $order);
/**
* Invoked when the off-site payment return.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
*/
public function onRedirectReturn(OrderInterface $order);
/**
* Invoked when the off-site payment way cancelled or failed.
*
* @param \Drupal\commerce_order\Entity\OrderInterface $order
* The order.
*/
public function onRedirectCancel(OrderInterface $order);
}
<?php
namespace Drupal\commerce_payment\Plugin\Commerce\PaymentGateway;
/**
* Defines the base interface for off-site payment gateways.
*/
interface OffsiteRedirectPaymentGatewayInterface extends OffsitePaymentGatewayInterface {
/**
* Gets the off-site redirect URL.
*
* If this is TRUE, a form wrapper and JavaScript snippet will be added that
* submits the off-site payment form, causing a redirect to the payment
* gateway's payment page.
*
* @return bool
* Whether to automatically redirect or not.
*/
public function getRedirectUrl();
}
......@@ -107,6 +107,9 @@ abstract class PaymentGatewayBase extends PluginBase implements PaymentGatewayIn
if ($this instanceof SupportsRefundsInterface) {
$default_forms['refund-payment'] = 'Drupal\commerce_payment\PluginForm\PaymentRefundForm';
}
if ($this instanceof OffsitePaymentGatewayInterface) {
$default_forms['offsite-payment'] = 'Drupal\commerce_payment\PluginForm\OffsitePaymentForm';
}
return $default_forms;
}
......
<?php
namespace Drupal\commerce_payment\PluginForm;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsiteRedirectPaymentGatewayInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
class OffsitePaymentForm extends PaymentGatewayFormBase {
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
/** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */
$payment = $this->entity;
$payment_gateway_plugin = $payment->getPaymentGateway()->getPlugin();
if ($payment_gateway_plugin instanceof OffsiteRedirectPaymentGatewayInterface) {
$form['#attached']['library'][] = 'commerce_payment/offsite_redirect';
$form['help'] = [
'#markup' => '<div class="checkout-help">' . t('Please wait while you are redirected to the payment server. If nothing happens within 10 seconds, please click on the button below.') . '</div>',
'#weight' => -10,
];
}
// Manually set parents so all of the off-site redirect inputs are on
// the root of the form.
foreach (Element::children($form) as $child) {
$form[$child]['#parents'] = [$child];
}
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
// Nothing. Off-site payment gateways do not submit forms to Drupal.
}
}
<?php
namespace Drupal\Tests\commerce_payment\Functional;
use Drupal\commerce_order\Entity\Order;
use Drupal\commerce_payment\Entity\Payment;
use Drupal\commerce_payment\Entity\PaymentGateway;
use Drupal\commerce_store\StoreCreationTrait;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Tests\commerce\Functional\CommerceBrowserTestBase;
/**
* Tests the integration between payments and checkout.
*
* @group commerce
*/
class PaymentCheckoutOffsiteRedirectTest extends CommerceBrowserTestBase {
use StoreCreationTrait;
/**
* The current user.
*
* @var \Drupal\Core\Session\AccountInterface