The Goal
A Drupal Commerce website may want to offer the same payment terms provided to customers purchasing products offline. If up-front payment is not required, then we need to create an option for customers to complete checkout without providing payment. Additional information may need to be collected during checkout for the "payment on account" option in lieu of a traditional payment method. For our business needs, this additional information takes the form of a Purchase Order number entered by the customer. During post-checkout processing, administrative users confirm the credit status for customers paying by purchase order and request alternative payment methods as needed.
Additionally, we want to provide an option for customers to enter a Purchase Order number during checkout, even if an actual payment (by credit card) is applied to the order.
Purchase Order number required only when Purchase Order payment method selected:

In summary, our requirements are fairly straightforward:
- Payment method "Purchase order" option offered to customers during checkout
- No validation/authentication process required.
- Should not create/add a "payment" to the order.
- Billing information is required.
- Option for customers to enter Purchase order number during checkout
- Required when "Purchase Order" selected as the payment method.
As a result, our solution is minimalistic. For more extensive functionality, you might want to consider the Commerce Purchase Order module.
A Solution
"Purchase order" payment method option
The "Payment process" checkout pane is the key to understanding how we can create a payment method "Purchase order" option that meets our requirements. See Drupal\commerce_payment\Plugin\Commerce\CheckoutPane\PaymentProcess::buildPaneForm
(in Drupal Commerce Payment module.)
The selected "payment option" is processed based on the payment gateway plugin type for the selected payment option. The plugin must implement one of these interfaces:
SupportsStoredPaymentMethodsInterface
OffsitePaymentGatewayInterface
ManualPaymentGatewayInterface
Of those options, the processing for ManualPaymentGatewayInterface
is the simplest: create a payment and redirect to the next checkout step.
So we can use the "Manual" payment gateway plugin as a model and implement a custom payment gateway plugin that implements the ManualPaymentGatewayInterface
interface. Though in contrast to the "Manual" payment gateway plugin provided by the Commerce Payment module, our custom plugin will do nothing. It has no payment instructions, never creates a payment, etc.
To collect billing information for the payment option, we can include that option in our plugin definition:
* requires_billing_information = TRUE,
The full implementation for our "Purchase Order" payment gateway plugin is included below. After implementing the plugin, rebuild caches and then configure a new payment gateway for your plugin. Like any other payment gateway, you can add conditions to limit availability to only certain customers/orders/products/etc. Make note of the machine name (ID) you use for your "Purchase Order" payment gateway since you'll need it for the custom checkout pane implementation described below.
Purchase order number field
To start, we can add a PO number field to our order type: a plain text field, not required. (This can be done simply through the administrative field UI.) Additionally, we'll create a new entity form mode for Order entities named "Checkout" (machine name checkout
) and add the PO Number field to the form display for the order type. Optionally, you may want to add other fields to the Checkout form display if there is other custom information you want to capture during checkout. We'll use both the PO number field and the custom form display for our checkout pane.
We create a custom checkout pane by extending Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase
and implementing methods:
buildPaneForm
validatePaneForm
submitPaneForm
We use EntityFormDisplay
class methods to build our form:
$form_display = EntityFormDisplay::collectRenderDisplay($this->order, 'checkout')->removeComponent('coupons');
$form_display->buildForm($this->order, $pane_form, $form_state);
In the collectRenderDisplay
method, we pass in the name of our custom order form display mode: checkout
. Then when we build the form, only fields added to that form display are included.
We need a bit of custom logic to conditionally require the PO number field whenever the "Purchase order" payment option is selected. Note that for the "value," you'll need to use the ID (machine name) of your "Purchase Order" payment gateway. If you named your payment gateway, "Purchase Order," it's probably purchase_order
.
Also, if the machine name of your custom Purchase order number field is not po_number
, you should replace that text with your custom field's ID/machine name. (It might be something like field_purchase_order_number
.)
if (isset($pane_form['po_number'])) {
$pane_form['po_number']['widget'][0]['value']['#states'] = [
'required' => [
':input[name="payment_information[payment_method]"]' => [
'value' => 'machine_name_of_payment_gateway',
],
],
];
}
Because our Purchase Order Number field is only required when the Purchase Order payment method is selected, we also need custom code in our form validation method:
$values = $form_state->getValue($pane_form['#parents']);
$payment_information = $form_state->getValue('payment_information');
if (
isset($payment_information['payment_method']) &&
$payment_information['payment_method'] == 'machine_name_of_payment_gateway' &&
empty($values['po_number'][0]['value'])
) {
$form_state->setError($pane_form['po_number']['widget'], $this->t('A PO must be specified when paying by purchase order.'));
}
The full implementation for our custom checkout pane plugin is included below. After implementing your plugin, edit the configuration for your checkout flow to include the pane. In our implementation, we include the pane in the Order information step, between Shipping information and Payment information.
One "gotcha" for administratively entered payments
Since our "Purchase Order" payment gateway does not actually create payments, we don't want to offer it as an option for adding payments administratively. To exclude the payment gateway, we can implement a custom event subscriber for the PaymentEvents::FILTER_PAYMENT_GATEWAYS
event. If the order is not a cart, we remove the "Purchase Order" payment gateway option. See the FilterPaymentGatewaysEventSubscriber
implementation below.
You will also need to define a service definition in your custom module's services.yml
file, like this:
mymodule.filter_payment_gateways_subscriber:
class: Drupal\mymodule\EventSubscriber\FilterPaymentGatewaysEventSubscriber
tags:
- { name: event_subscriber }
Implementation:
"Purchase order" commerce payment gateway plugin:
<?php
namespace Drupal\mymodule\Plugin\Commerce\PaymentGateway;
use Drupal\commerce_payment\Entity\PaymentInterface;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\PaymentGatewayBase;
use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\ManualPaymentGatewayInterface;
use Drupal\commerce_price\Price;
/**
* Provides the Purchase Order payment gateway.
*
* @CommercePaymentGateway(
* id = "mymodule_purchase_order",
* label = "Purchase Order",
* display_label = "Purchase Order",
* modes = {
* "n/a" = @Translation("N/A"),
* },
* requires_billing_information = TRUE,
* )
*/
class PurchaseOrder extends PaymentGatewayBase implements ManualPaymentGatewayInterface {
/**
* {@inheritdoc}
*/
public function buildPaymentInstructions(PaymentInterface $payment) {
return [];
}
/**
* {@inheritdoc}
*/
public function canVoidPayment(PaymentInterface $payment) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function voidPayment(PaymentInterface $payment) {
}
/**
* {@inheritdoc}
*/
public function canRefundPayment(PaymentInterface $payment) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function refundPayment(PaymentInterface $payment, Price $amount = NULL) {
}
/**
* {@inheritdoc}
*/
public function createPayment(PaymentInterface $payment, $received = FALSE) {
}
/**
* {@inheritdoc}
*/
public function receivePayment(PaymentInterface $payment, Price $amount = NULL) {
}
}
"Additional information" commerce checkout pane plugin:
<?php
namespace Drupal\mymodule\Plugin\Commerce\CheckoutPane;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase;
use Drupal\Core\Entity\Entity\EntityFormDisplay;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides the additional order information pane.
*
* @CommerceCheckoutPane(
* id = "mymodule_commerce_purchase_order_number",
* label = @Translation("Additional information"),
* display_label = @Translation("Additional information"),
* wrapper_element = "fieldset",
* )
*/
class AdditionalOrderInformationPane extends CheckoutPaneBase {
/**
* {@inheritdoc}
*/
public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
$form_display = EntityFormDisplay::collectRenderDisplay($this->order, 'checkout')->removeComponent('coupons');
$form_display->buildForm($this->order, $pane_form, $form_state);
if (isset($pane_form['po_number'])) {
// Field is required when "Purchase order" payment option is selected.
$pane_form['po_number']['widget'][0]['value']['#states'] = [
'required' => [
':input[name="payment_information[payment_method]"]' => [
'value' => 'purchase_order',
],
],
];
}
return $pane_form;
}
/**
* {@inheritdoc}
*/
public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
$form_display = EntityFormDisplay::collectRenderDisplay($this->order, 'checkout');
$form_display->extractFormValues($this->order, $pane_form, $form_state);
$form_display->validateFormValues($this->order, $pane_form, $form_state);
// Validate PO number field.
$values = $form_state->getValue($pane_form['#parents']);
$payment_information = $form_state->getValue('payment_information');
if (
isset($payment_information['payment_method']) &&
$payment_information['payment_method'] == 'purchase_order' &&
empty($values['po_number'][0]['value'])
) {
$form_state->setError($pane_form['po_number']['widget'], $this->t('A PO must be specified when paying by purchase order.'));
}
}
/**
* {@inheritdoc}
*/
public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
$form_display = EntityFormDisplay::collectRenderDisplay($this->order, 'checkout');
$form_display->extractFormValues($this->order, $pane_form, $form_state);
}
}
FilterPaymentGatewaysEventSubscriber class:
<?php
namespace Drupal\mymodule\EventSubscriber;
use Drupal\commerce_payment\Event\FilterPaymentGatewaysEvent;
use Drupal\commerce_payment\Event\PaymentEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Event subscriber to handle payment events.
*/
class FilterPaymentGatewaysEventSubscriber implements EventSubscriberInterface {
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
PaymentEvents::FILTER_PAYMENT_GATEWAYS => 'onFilter',
];
}
/**
* Filters out payment gateways.
*
* @param \Drupal\commerce_payment\Event\FilterPaymentGatewaysEvent $event
* The event.
*/
public function onFilter(FilterPaymentGatewaysEvent $event) {
$order = $event->getOrder();
if ($order->get('cart')->value) {
return;
}
$payment_gateways = $event->getPaymentGateways();
$excluded_gateways = ['purchase_order'];
foreach ($payment_gateways as $payment_gateway_id => $payment_gateway) {
if (in_array($payment_gateway->id(), $excluded_gateways)) {
unset($payment_gateways[$payment_gateway_id]);
}
}
$event->setPaymentGateways($payment_gateways);
}
}
Comments