All articles

Show a Drupal Commerce checkout pane only for card payments

To show a Drupal Commerce checkout pane only when a card payment method is selected, don't use Form API #states: the payment method radios are rebuilt by Ajax, so the states bindings go stale. Instead, put the list of card-type radio values on the pane as a data attribute, toggle the pane from a Drupal behaviour with a delegated change handler, and make the pane's validation return early when a non-card method is selected. A teammate's implementation of this pattern is what I describe here.

Why doesn't #states work for the payment method radios?

The requirement sounds simple: a pane holding a "I understand a card processing fee applies" checkbox must be visible for credit card payments and hidden for anything else, such as a bank transfer or an offline method. #states is the usual Drupal answer to conditional visibility, but it works by attaching handlers to a specific set of form elements. In the Commerce payment information pane, selecting a radio triggers an Ajax rebuild that replaces the radio group and the sub-form beneath it. The elements the states system was watching no longer exist, and the new ones are not wired up the same way. The result is a pane that behaves until the first Ajax refresh.

The same rebuild is why a naive $('input[name=...]').on('change', ...) bound once in a behaviour stops firing: you bind to elements that are then thrown away.

How do I tell which radio values are card methods?

This is the less obvious part. The radios do not carry a clean "card" flag. Their values come in three shapes:

  • a bare payment gateway ID, for gateways with no stored methods;
  • a new--{type}--{gateway} string, for adding a new method with a given gateway;
  • a numeric ID, for a saved payment method belonging to the customer.

To decide whether a value is a card method, resolve it to a gateway and compare that with the gateway configured for the pane. Here is a minimal version:

use Drupal\commerce_payment\Entity\PaymentMethod;

protected function gatewayIdFromRadioValue($value): ?string {
  if ($value === NULL || $value === '') {
    return NULL;
  }
  if (is_numeric($value)) {
    $method = PaymentMethod::load($value);
    return $method ? $method->getPaymentGatewayId() : NULL;
  }
  if (str_starts_with($value, 'new--')) {
    $parts = explode('--', $value);
    return end($parts);
  }
  return $value;
}

Run every option of the payment method element through that function when building the pane, and keep the values whose gateway matches. If the customer has fewer than two options, there is no choice to react to, so the pane should simply stay visible.

How do I pass the card values to the browser?

Put them on the pane's wrapper as a JSON data attribute and attach a library. The server decides what counts as a card method; the browser only reads the list.

$pane_form['#attributes']['class'][] = 'cc-fee-pane';
$pane_form['#attributes']['data-cc-methods'] = Json::encode($card_values);
$pane_form['#attached']['library'][] = 'my_module/pane';
(function (Drupal, $, once) {
  const selector = 'input[name="payment_information[payment_method]"]';

  function toggle() {
    $('.cc-fee-pane').each(function () {
      const $pane = $(this);
      const cardValues = JSON.parse($pane.attr('data-cc-methods') || '[]');
      const $radios = $(selector);
      let show = true;
      if ($radios.length) {
        const $checked = $radios.filter(':checked');
        show = $checked.length === 0 || cardValues.includes($checked.val());
      }
      $pane.toggle(show);
    });
  }

  Drupal.behaviors.ccFeePane = {
    attach(context) {
      // Delegate from the document: the radios are replaced by Ajax.
      once('cc-fee-pane', 'body', context).forEach(() => {
        $(document).on('change', selector, toggle);
      });
      toggle();
    },
  };
})(Drupal, jQuery, once);

Two details matter. The handler is delegated from document and bound once through once(), so it survives the Ajax replacement and never stacks up duplicate listeners. And toggle() runs on every attach, which covers the pane's own initial render and any later rebuild that restores a previously chosen radio.

Why must the server skip validation for a hidden pane?

If the checkbox is marked #required, a visitor paying by bank transfer cannot proceed: the browser hides the field, but Drupal still insists on a value. Hiding is a presentation concern and validation is a server concern, so both need handling.

The approach is to set #required to FALSE on the element and enforce it by hand in validatePaneForm(), but only when a card method is selected:

public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
  $selected = $form_state->getValue(['payment_information', 'payment_method']);
  $gateway = $this->gatewayIdFromRadioValue($selected);
  if ($selected !== NULL && $selected !== '' && $gateway !== $this->configuration['card_gateway']) {
    return;   // the checkbox is hidden for this method
  }
  // ... enforce the required checkbox here
}

I like that the same helper decides visibility in the form build and eligibility in validation. There is one definition of "card method", and it lives in PHP. The JavaScript never has to know about gateways.

When would I reach for this?

Use it whenever a Commerce checkout pane depends on the chosen payment method: surcharge acknowledgements, terms specific to one gateway, or extra fields for a purchase-order method. If the controlling element is not replaced by Ajax, plain #states is simpler and you should use it.

Key takeaways

  • Form API #states is unreliable for elements that an Ajax rebuild replaces, and the Commerce payment method radios are such elements.
  • Radio values come as a gateway ID, a new--type--gateway string or a saved method ID, so resolve each to its gateway before comparing.
  • Pass the list of matching values to the browser as a JSON data attribute and toggle the pane with a delegated change handler bound once through once().
  • A hidden required field still fails server-side validation, so skip the pane's validation unless the relevant payment method is selected.
  • Keep the decision about what counts as a card method in PHP, so the form build and the validation share one definition.