From f9875d38613f7a69c0db90b934e299b9c017462b Mon Sep 17 00:00:00 2001
From: Matt Glaman <nmd.matt@gmail.com>
Date: Sat, 3 Dec 2016 11:23:40 -0600
Subject: [PATCH] Issue #2785591: Initial implementation of an off-site API.

---
 .../CheckoutFlow/CheckoutFlowBase.php         |  23 ++-
 .../CheckoutFlow/CheckoutFlowInterface.php    |  10 +
 .../CheckoutFlow/MultistepDefault.php         |   1 +
 .../payment/commerce_payment.libraries.yml    |   9 +
 modules/payment/commerce_payment.routing.yml  |  23 +++
 modules/payment/js/offiste-redirect.js        |  23 +++
 .../Controller/PaymentCheckoutController.php  |  53 ++++++
 .../CheckoutPane/PaymentInformation.php       | 126 ++++++++++---
 .../Commerce/CheckoutPane/PaymentProcess.php  |  60 +++---
 .../OffsitePaymentGatewayBase.php             |  33 ++++
 .../OffsitePaymentGatewayInterface.php        |  50 +++++
 ...OffsiteRedirectPaymentGatewayInterface.php |  22 +++
 .../PaymentGateway/PaymentGatewayBase.php     |   3 +
 .../src/PluginForm/OffsitePaymentForm.php     |  43 +++++
 .../PaymentCheckoutOffsiteRedirectTest.php    | 172 ++++++++++++++++++
 .../commerce_payment_example.routing.yml      |  16 ++
 .../commerce_payment_example.schema.yml       |   6 +
 .../Controller/DummyRedirectController.php    |  75 ++++++++
 .../PaymentGateway/OffsiteRedirectExample.php | 107 +++++++++++
 .../OffsiteRedirectExampleInterface.php       |   9 +
 .../Offsite/OffsiteRedirectPaymentForm.php    |  51 ++++++
 21 files changed, 858 insertions(+), 57 deletions(-)
 create mode 100644 modules/payment/js/offiste-redirect.js
 create mode 100644 modules/payment/src/Controller/PaymentCheckoutController.php
 create mode 100644 modules/payment/src/Plugin/Commerce/PaymentGateway/OffsitePaymentGatewayBase.php
 create mode 100644 modules/payment/src/Plugin/Commerce/PaymentGateway/OffsitePaymentGatewayInterface.php
 create mode 100644 modules/payment/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectPaymentGatewayInterface.php
 create mode 100644 modules/payment/src/PluginForm/OffsitePaymentForm.php
 create mode 100644 modules/payment/tests/src/Functional/PaymentCheckoutOffsiteRedirectTest.php
 create mode 100644 modules/payment_example/commerce_payment_example.routing.yml
 create mode 100644 modules/payment_example/src/Controller/DummyRedirectController.php
 create mode 100644 modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectExample.php
 create mode 100644 modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectExampleInterface.php
 create mode 100644 modules/payment_example/src/PluginForm/Offsite/OffsiteRedirectPaymentForm.php

diff --git a/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php b/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php
index 2e66e9b71..ab651dc94 100644
--- a/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php
+++ b/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowBase.php
@@ -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;
   }
 
diff --git a/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowInterface.php b/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowInterface.php
index 9986afc23..127fd4009 100644
--- a/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowInterface.php
+++ b/modules/checkout/src/Plugin/Commerce/CheckoutFlow/CheckoutFlowInterface.php
@@ -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.
    *
diff --git a/modules/checkout/src/Plugin/Commerce/CheckoutFlow/MultistepDefault.php b/modules/checkout/src/Plugin/Commerce/CheckoutFlow/MultistepDefault.php
index c063190d9..6d5258245 100644
--- a/modules/checkout/src/Plugin/Commerce/CheckoutFlow/MultistepDefault.php
+++ b/modules/checkout/src/Plugin/Commerce/CheckoutFlow/MultistepDefault.php
@@ -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();
diff --git a/modules/payment/commerce_payment.libraries.yml b/modules/payment/commerce_payment.libraries.yml
index 37225adb1..ed2a64176 100644
--- a/modules/payment/commerce_payment.libraries.yml
+++ b/modules/payment/commerce_payment.libraries.yml
@@ -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
diff --git a/modules/payment/commerce_payment.routing.yml b/modules/payment/commerce_payment.routing.yml
index ed5156d0d..04f8c0e5c 100644
--- a/modules/payment/commerce_payment.routing.yml
+++ b/modules/payment/commerce_payment.routing.yml
@@ -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
diff --git a/modules/payment/js/offiste-redirect.js b/modules/payment/js/offiste-redirect.js
new file mode 100644
index 000000000..8b3b1b150
--- /dev/null
+++ b/modules/payment/js/offiste-redirect.js
@@ -0,0 +1,23 @@
+/**
+ * @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);
diff --git a/modules/payment/src/Controller/PaymentCheckoutController.php b/modules/payment/src/Controller/PaymentCheckoutController.php
new file mode 100644
index 000000000..19af26549
--- /dev/null
+++ b/modules/payment/src/Controller/PaymentCheckoutController.php
@@ -0,0 +1,53 @@
+<?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());
+  }
+
+}
diff --git a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php
index d7bdbdbd9..abb7c6a7d 100644
--- a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php
+++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php
@@ -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,10 +99,12 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
     }
     else {
       $billing_profile = $this->order->getBillingProfile();
-      $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);
+      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,8 +243,23 @@ 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']);
-    if (!isset($values['payment_method'])) {
-      $form_state->setError($complete_form, $this->noPaymentGatewayErrorMessage());
+
+    /** @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);
     }
   }
 
@@ -202,19 +268,37 @@ class PaymentInformation extends CheckoutPaneBase implements ContainerFactoryPlu
    */
   public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
     $values = $form_state->getValue($pane_form['#parents']);
-    if (is_numeric($values['payment_method'])) {
-      /** @var \Drupal\commerce_payment\PaymentMethodStorageInterface $payment_method_storage */
-      $payment_method_storage = $this->entityTypeManager->getStorage('commerce_payment_method');
-      $payment_method = $payment_method_storage->load($values['payment_method']);
+
+    /** @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');
+        $payment_method = $payment_method_storage->load($values['payment_method']);
+      }
+      else {
+        $payment_method = $values['add_payment_method'];
+      }
+
+      /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
+      $this->order->payment_gateway = $payment_method->getPaymentGateway();
+      $this->order->payment_method = $payment_method;
+      $this->order->setBillingProfile($payment_method->getBillingProfile());
     }
     else {
-      $payment_method = $values['add_payment_method'];
+      /** @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);
     }
-
-    /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */
-    $this->order->payment_gateway = $payment_method->getPaymentGateway();
-    $this->order->payment_method = $payment_method;
-    $this->order->setBillingProfile($payment_method->getBillingProfile());
   }
 
   /**
diff --git a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php
index 783b95a3b..de0067553 100644
--- a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php
+++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php
@@ -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();
 
+    $payment_storage = $this->entityTypeManager->getStorage('commerce_payment');
+    $payment = $payment_storage->create([
+      'state' => 'new',
+      'amount' => $this->order->getTotalPrice(),
+      'payment_gateway' => $payment_gateway->id(),
+      'order_id' => $this->order->id(),
+    ]);
+
     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->payment_method = $this->order->payment_method->entity;
         $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());
+        $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);
   }
 
 }
diff --git a/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsitePaymentGatewayBase.php b/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsitePaymentGatewayBase.php
new file mode 100644
index 000000000..da3ae1af3
--- /dev/null
+++ b/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsitePaymentGatewayBase.php
@@ -0,0 +1,33 @@
+<?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]);
+  }
+
+}
diff --git a/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsitePaymentGatewayInterface.php b/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsitePaymentGatewayInterface.php
new file mode 100644
index 000000000..d00e9c3f8
--- /dev/null
+++ b/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsitePaymentGatewayInterface.php
@@ -0,0 +1,50 @@
+<?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);
+
+}
diff --git a/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectPaymentGatewayInterface.php b/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectPaymentGatewayInterface.php
new file mode 100644
index 000000000..7bc3a6488
--- /dev/null
+++ b/modules/payment/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectPaymentGatewayInterface.php
@@ -0,0 +1,22 @@
+<?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();
+
+}
diff --git a/modules/payment/src/Plugin/Commerce/PaymentGateway/PaymentGatewayBase.php b/modules/payment/src/Plugin/Commerce/PaymentGateway/PaymentGatewayBase.php
index 5f8343747..db5920768 100644
--- a/modules/payment/src/Plugin/Commerce/PaymentGateway/PaymentGatewayBase.php
+++ b/modules/payment/src/Plugin/Commerce/PaymentGateway/PaymentGatewayBase.php
@@ -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;
   }
diff --git a/modules/payment/src/PluginForm/OffsitePaymentForm.php b/modules/payment/src/PluginForm/OffsitePaymentForm.php
new file mode 100644
index 000000000..d61fc07cd
--- /dev/null
+++ b/modules/payment/src/PluginForm/OffsitePaymentForm.php
@@ -0,0 +1,43 @@
+<?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.
+  }
+
+}
diff --git a/modules/payment/tests/src/Functional/PaymentCheckoutOffsiteRedirectTest.php b/modules/payment/tests/src/Functional/PaymentCheckoutOffsiteRedirectTest.php
new file mode 100644
index 000000000..0d732da75
--- /dev/null
+++ b/modules/payment/tests/src/Functional/PaymentCheckoutOffsiteRedirectTest.php
@@ -0,0 +1,172 @@
+<?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
+   */
+  protected $account;
+
+  /**
+   * The product.
+   *
+   * @var \Drupal\commerce_product\Entity\ProductInterface
+   */
+  protected $product;
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'commerce_product',
+    'commerce_cart',
+    'commerce_checkout',
+    'commerce_payment',
+    'commerce_payment_example',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $store = $this->createStore('Demo', 'demo@example.com', 'default', TRUE);
+
+    $variation = $this->createEntity('commerce_product_variation', [
+      'type' => 'default',
+      'sku' => strtolower($this->randomMachineName()),
+      'price' => [
+        'number' => '29.99',
+        'currency_code' => 'USD',
+      ],
+    ]);
+
+    /** @var \Drupal\commerce_product\Entity\ProductInterface $product */
+    $this->product = $this->createEntity('commerce_product', [
+      'type' => 'default',
+      'title' => 'My product',
+      'variations' => [$variation],
+      'stores' => [$store],
+    ]);
+
+    /** @var \Drupal\commerce_payment\Entity\PaymentGateway $gateway */
+    $gateway = PaymentGateway::create([
+      'id' => 'example_offsite_redirect',
+      'label' => 'Example',
+      'plugin' => 'example_offsite_redirect',
+    ]);
+    $gateway->getPlugin()->setConfiguration([
+      'method' => 'redirect_post',
+      'payment_method_types' => ['credit_card'],
+    ]);
+    $gateway->save();
+
+    // Cheat so we don't need JS to interact w/ Address field widget.
+    /** @var \Drupal\Core\Entity\Display\EntityFormDisplayInterface $customer_form_display */
+    $customer_form_display = EntityFormDisplay::load('profile.customer.default');
+    $address_component = $customer_form_display->getComponent('address');
+    $address_component['settings']['default_country'] = 'US';
+    $customer_form_display->setComponent('address', $address_component);
+    $customer_form_display->save();
+
+  }
+
+  /**
+   * Tests than an order can go through checkout steps.
+   */
+  public function testCheckoutWithOffsiteRedirectPost() {
+    $this->drupalGet($this->product->toUrl()->toString());
+    $this->submitForm([], 'Add to cart');
+    $cart_link = $this->getSession()->getPage()->findLink('your cart');
+    $cart_link->click();
+    $this->submitForm([], 'Checkout');
+    $this->assertSession()->pageTextContains('Order Summary');
+    $this->submitForm([
+      'payment_information[address][0][given_name]' => 'Johnny',
+      'payment_information[address][0][family_name]' => 'Appleseed',
+      'payment_information[address][0][address_line1]' => '123 New York Drive',
+      'payment_information[address][0][locality]' => 'New York City',
+      'payment_information[address][0][administrative_area]' => 'NY',
+      'payment_information[address][0][postal_code]' => '10001',
+    ], 'Continue to review');
+    $this->assertSession()->pageTextContains('Contact information');
+    $this->assertSession()->pageTextContains($this->loggedInUser->getEmail());
+    $this->assertSession()->pageTextContains('Payment information');
+    $this->assertSession()->pageTextContains('Order Summary');
+    $this->submitForm([], 'Pay and complete purchase');
+    // No JS so we need to manually click the button to submit payment.
+    $this->submitForm([], 'Pay and complete purchase');
+    $this->assertSession()->pageTextContains('Your order number is 1. You can view your order on your account page when logged in.');
+    $order = Order::load(1);
+    $payment_gateway = $order->payment_gateway->entity;
+    $this->assertEquals('example_offsite_redirect', $payment_gateway->id());
+
+    // Verify that a payment was created.
+    $payment = Payment::load(1);
+    $this->assertNotNull($payment);
+    $this->assertEquals($payment->getAmount(), $order->getTotalPrice());
+  }
+
+  /**
+   * Tests the transaction mode in Authorize Only.
+   */
+  public function testCheckoutWithOffsiteRedirect302() {
+    // Set checkout flow to authorize only.
+    $payment_gateway = PaymentGateway::load('example_offsite_redirect');
+    $payment_gateway->getPlugin()->setConfiguration([
+      'method' => 'redirect_302',
+      'payment_method_types' => ['credit_card'],
+    ]);
+    $payment_gateway->save();
+
+    $this->drupalGet($this->product->toUrl()->toString());
+    $this->submitForm([], 'Add to cart');
+    $cart_link = $this->getSession()->getPage()->findLink('your cart');
+    $cart_link->click();
+    $this->submitForm([], 'Checkout');
+    $this->assertSession()->pageTextContains('Order Summary');
+    $this->submitForm([
+      'payment_information[address][0][given_name]' => 'Johnny',
+      'payment_information[address][0][family_name]' => 'Appleseed',
+      'payment_information[address][0][address_line1]' => '123 New York Drive',
+      'payment_information[address][0][locality]' => 'New York City',
+      'payment_information[address][0][administrative_area]' => 'NY',
+      'payment_information[address][0][postal_code]' => '10001',
+    ], 'Continue to review');
+    $this->assertSession()->pageTextContains('Contact information');
+    $this->assertSession()->pageTextContains($this->loggedInUser->getEmail());
+    $this->assertSession()->pageTextContains('Payment information');
+    $this->assertSession()->pageTextContains('Order Summary');
+    $this->submitForm([], 'Pay and complete purchase');
+    $this->assertSession()->pageTextContains('Your order number is 1. You can view your order on your account page when logged in.');
+    $order = Order::load(1);
+    $payment_gateway = $order->payment_gateway->entity;
+    $this->assertEquals('example_offsite_redirect', $payment_gateway->id());
+    // Verify that a payment was created.
+    $payment = Payment::load(1);
+    $this->assertNotNull($payment);
+    $this->assertEquals($payment->getAmount(), $order->getTotalPrice());
+  }
+
+}
diff --git a/modules/payment_example/commerce_payment_example.routing.yml b/modules/payment_example/commerce_payment_example.routing.yml
new file mode 100644
index 000000000..1a513b763
--- /dev/null
+++ b/modules/payment_example/commerce_payment_example.routing.yml
@@ -0,0 +1,16 @@
+commerce_payment_example.dummy_redirect_post:
+  path: 'commerce_payment_example/dummy_redirect_post'
+  defaults:
+    _controller: '\Drupal\commerce_payment_example\Controller\DummyRedirectController::post'
+  options:
+    no_cache: TRUE
+  requirements:
+    _access: 'TRUE'
+commerce_payment_example.dummy_redirect_302:
+  path: 'commerce_payment_example/dummy_redirect_302'
+  defaults:
+    _controller: '\Drupal\commerce_payment_example\Controller\DummyRedirectController::on302'
+  options:
+    no_cache: TRUE
+  requirements:
+    _access: 'TRUE'
diff --git a/modules/payment_example/config/schema/commerce_payment_example.schema.yml b/modules/payment_example/config/schema/commerce_payment_example.schema.yml
index c80f9e40f..00bcb019e 100644
--- a/modules/payment_example/config/schema/commerce_payment_example.schema.yml
+++ b/modules/payment_example/config/schema/commerce_payment_example.schema.yml
@@ -4,3 +4,9 @@ commerce_payment.commerce_payment_gateway.plugin.example_onsite:
     api_key:
       type: string
       label: 'API key'
+commerce_payment.commerce_payment_gateway.plugin.example_offsite_redirect:
+  type: commerce_payment_gateway_configuration
+  mapping:
+    method:
+      type: string
+      label: 'Whether to use POST or 302 Redirect'
diff --git a/modules/payment_example/src/Controller/DummyRedirectController.php b/modules/payment_example/src/Controller/DummyRedirectController.php
new file mode 100644
index 000000000..48d4d578c
--- /dev/null
+++ b/modules/payment_example/src/Controller/DummyRedirectController.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\commerce_payment_example\Controller;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Routing\TrustedRedirectResponse;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * This is a dummy controller for mocking an off-site gateway.
+ */
+class DummyRedirectController implements ContainerInjectionInterface {
+
+  /**
+   * The current request.
+   *
+   * @var \Symfony\Component\HttpFoundation\Request
+   */
+  protected $currentRequest;
+
+  /**
+   * Constructs a new DummyRedirectController object.
+   *
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   */
+  public function __construct(RequestStack $request_stack) {
+    $this->currentRequest = $request_stack->getCurrentRequest();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('request_stack')
+    );
+  }
+
+  /**
+   * Callback method which accepts POST.
+   *
+   * @throws \Drupal\commerce\Response\NeedsRedirectException
+   */
+  public function post() {
+    $cancel = $this->currentRequest->request->get('cancel');
+    $return = $this->currentRequest->request->get('return');
+    $total = $this->currentRequest->request->get('total');
+
+    if ($total > 20) {
+      return new TrustedRedirectResponse($return);
+    }
+
+    return new TrustedRedirectResponse($cancel);
+  }
+
+  /**
+   * Callback method which reacts to GET from a 302 redirect.
+   *
+   * @throws \Drupal\commerce\Response\NeedsRedirectException
+   */
+  public function on302() {
+    $cancel = $this->currentRequest->query->get('cancel');
+    $return = $this->currentRequest->query->get('return');
+    $total = $this->currentRequest->query->get('total');
+
+    if ($total > 20) {
+      return new TrustedRedirectResponse($return);
+    }
+
+    return new TrustedRedirectResponse($cancel);
+  }
+
+}
diff --git a/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectExample.php b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectExample.php
new file mode 100644
index 000000000..abc2d67c8
--- /dev/null
+++ b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectExample.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Drupal\commerce_payment_example\Plugin\Commerce\PaymentGateway;
+
+use Drupal\commerce_order\Entity\OrderInterface;
+use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Provides the Offsite Redirect payment gateway.
+ *
+ * @CommercePaymentGateway(
+ *   id = "example_offsite_redirect",
+ *   label = "Example Offsite (Redirect)",
+ *   display_label = "Example Offsite (Redirect)",
+ *    forms = {
+ *     "offsite-payment" = "Drupal\commerce_payment_example\PluginForm\Offsite\OffsiteRedirectPaymentForm",
+ *   },
+ *   payment_method_types = {"credit_card"},
+ *   credit_card_types = {
+ *     "amex", "dinersclub", "discover", "jcb", "maestro", "mastercard", "visa",
+ *   },
+ * )
+ */
+class OffsiteRedirectExample extends OffsitePaymentGatewayBase implements OffsiteRedirectExampleInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return [
+      'mode' => 'redirect_post',
+    ] + parent::defaultConfiguration();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildConfigurationForm($form, $form_state);
+
+    $form['method'] = [
+      '#type' => 'radios',
+      '#title' => $this->t('Method'),
+      '#options' => [
+        'redirect_post' => $this->t('Redirect via POST'),
+        'redirect_302' => $this->t('Redirect via 302 header'),
+      ],
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  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['method'] = $values['method'];
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onRedirectReturn(OrderInterface $order) {
+    $current_request = \Drupal::getContainer()->get('request_stack')->getCurrentRequest();
+    // Create the payment.
+    $payment_storage = \Drupal::entityTypeManager()->getStorage('commerce_payment');
+    $payment = $payment_storage->create([
+      'state' => 'authorization',
+      'amount' => $order->getTotalPrice(),
+      // Gateway plugins cannot reach their matching config entity directly.
+      'payment_gateway' => $order->payment_gateway->entity->id(),
+      'order_id' => $order->id(),
+      'test' => $this->getMode() == 'test',
+      'remote_id' => $current_request->query->get('txn_id'),
+      'remote_state' => $current_request->query->get('payment_status'),
+      'authorized' => REQUEST_TIME,
+    ]);
+    $payment->save();
+    drupal_set_message('Payment was processed');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onRedirectCancel(OrderInterface $order) {
+    drupal_set_message('Payment was cancelled.', 'warning');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRedirectUrl() {
+    // If using a 302 method most gateways require you to do some kind of
+    // "handshake" procedure to send them order data and they return a unique
+    // URL. So we only return one for POST methods.
+    if ($this->configuration['method'] == 'redirect_post') {
+      return Url::fromRoute('commerce_payment_example.dummy_redirect_post')->toString();
+    }
+  }
+
+}
diff --git a/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectExampleInterface.php b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectExampleInterface.php
new file mode 100644
index 000000000..5162417a5
--- /dev/null
+++ b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirectExampleInterface.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\commerce_payment_example\Plugin\Commerce\PaymentGateway;
+
+use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsiteRedirectPaymentGatewayInterface;
+
+interface OffsiteRedirectExampleInterface extends OffsiteRedirectPaymentGatewayInterface {
+
+}
diff --git a/modules/payment_example/src/PluginForm/Offsite/OffsiteRedirectPaymentForm.php b/modules/payment_example/src/PluginForm/Offsite/OffsiteRedirectPaymentForm.php
new file mode 100644
index 000000000..fa3848987
--- /dev/null
+++ b/modules/payment_example/src/PluginForm/Offsite/OffsiteRedirectPaymentForm.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\commerce_payment_example\PluginForm\Offsite;
+
+use Drupal\commerce\Response\NeedsRedirectException;
+use Drupal\commerce_payment\PluginForm\OffsitePaymentForm;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+class OffsiteRedirectPaymentForm extends OffsitePaymentForm {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */
+    $payment = $this->entity;
+    /** @var \Drupal\commerce_payment_example\Plugin\Commerce\PaymentGateway\OffsiteRedirectExampleInterface $payment_gateway_plugin */
+    $payment_gateway_plugin = $payment->getPaymentGateway()->getPlugin();
+    $order = $payment->getOrder();
+    $mode = $payment_gateway_plugin->getConfiguration()['method'];
+
+    if ($mode == 'redirect_post') {
+      $form['cancel'] = [
+        '#type' => 'hidden',
+        '#value' => $payment_gateway_plugin->getRedirectCancelUrl($order)->toString(),
+      ];
+      $form['return'] = [
+        '#type' => 'hidden',
+        '#value' => $payment_gateway_plugin->getRedirectReturnUrl($order)->toString(),
+      ];
+      $form['total'] = [
+        '#type' => 'hidden',
+        '#value' => $payment->getAmount()->getNumber(),
+      ];
+    }
+    else {
+      throw new NeedsRedirectException(Url::fromRoute('commerce_payment_example.dummy_redirect_302', [], [
+        'absolute' => TRUE,
+        'query' => [
+          'cancel' => $payment_gateway_plugin->getRedirectCancelUrl($order)->toString(),
+          'return' => $payment_gateway_plugin->getRedirectReturnUrl($order)->toString(),
+          'total' => $payment->getAmount()->getNumber(),
+        ],
+      ])->toString());
+    }
+
+    return parent::buildConfigurationForm($form, $form_state);
+  }
+
+}
-- 
GitLab