Problem/Motivation

When a DonationItemPane is instantiated, it stores the donation item on an internal property. When the pane's form is submittedDonationItemPaneBase::submitPaneForm, that donation item is saved. However, the donation object stored in the pane instance holds all data as it existed when the pane was created. This means that if the donation order item is updated between pane instantiation and form submission, those changes are overridden with the values on the order item as they existed when the pane was instantiated.

Steps to reproduce

Add a custom donation_pane_validate() or donation_pane_submit() as below that updates the price of the donation.

function my_donation_pane_validate(array $form, \Drupal\Core\Form\FormStateInterface $form_state) {
  $route = \Drupal::routeMatch();
  /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
  $order = $route->getParameter('commerce_order');
  foreach ($order->getItems() as $order_item) {
    if ($order_item->get('type')->isEmpty()
      || $order_item->get('type')->target_id !== 'donation') {
      continue;
    }

    $set_unit_price = 300; // Some logic to get your new donation value.
    $price = new \Drupal\commerce_price\Price(
      $set_unit_price,
      $order->getStore()->getDefaultCurrencyCode()
    );

    // These are both needed as the donation amount is
    // used by commerce_donation_flow separately from the unit price
    $order_item->setUnitPrice($price, TRUE);
    $order_item->set('field_donation_amount', $price);
    $order_item->save();
  }
}

If the validation function updated the price from $10 to $300, submitForm reverts the price back to $10 because the original version of the order item, with the old unit price, is being saved.

Proposed resolution

Add a refresher method to ensure that the donationItem prop is current before executing the save. Attaching a patch to achieve this.

Comments

mrweiner created an issue. See original summary.

mrweiner’s picture

Issue summary: View changes
mrweiner’s picture

mrweiner’s picture

Status: Active » Needs review
mrweiner’s picture

Actually this may need work. I see that you're setting data on $this->donationItem in the validate function as well. I don't know whether or not this solution conflicts at all with what's going on in there.

mrweiner’s picture

Another note, submitForm was also problematic if the orderItem is deleted in the custom validate/submit callback. If instead of the snippet in the issue description you do something like:

function my_donation_pane_validate(array $form, \Drupal\Core\Form\FormStateInterface $form_state) {
  $route = \Drupal::routeMatch();
  /** @var \Drupal\commerce_order\Entity\OrderInterface $order */
  $order = $route->getParameter('commerce_order');
  foreach ($order->getItems() as $order_item) {
    if ($order_item->get('type')->isEmpty()
      || $order_item->get('type')->target_id !== 'donation') {
      continue;
    }
    
    $order_item->delete();
  }
}

There's some error like "cannot update entity ID during save," which I think is from the same bug. The order item entity has been deleted, but the pane is saving the old instance with the ID that not longer exists, causing some sort of conflict in Drupal's internal entity handler. I wonder if we really need to save the item in submitForm at all?

fathershawn’s picture

In our original scenario, the OrderItem was the purchaseable entity. A pane for in a step essentially built the data for the purchase on each step: amount and honoree.

This validate method in DonationItemPaneBase should be updating the pane's copy of the item with the values on the pane's form.

/**
   * {@inheritdoc}
   */
  public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
    // Our form comes from the entity form builder.
    $form_state->cleanValues();
    $this->buildDonationItem($pane_form, $form_state);
    $this->formDisplay->validateFormValues($this->donationItem, $pane_form, $form_state);
  }

I'll re-check. Thanks for your help in getting a solid, generalized, code base.

mrweiner’s picture

No problem! :D

This validate method in DonationItemPaneBase should be updating the pane's copy of the item with the values on the pane's form.

Okay yeah that's what I thought. I think the problems are that this is ineffective if either the changes happen outside of the context of the $form_state or any custom callbacks fire between the built-in validatePaneForm and submitPaneForm, as both situations will lead to a mismatch in internal vs and external donationItem metadata.

fathershawn’s picture

Since the OrderItem is what's being purchased here, and it doesn't get saved until submit, it seems like we need to operate on a locally held copy of the it. Hooks or callbacks can't load it from storage. If the local copy isn't in the pane, the only other option that occurs to me immediately is the FormState object since we're in form context.

Also, there's no need to load the order from the route. The checkout flow object has it and stores it in \Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase::__construct

mrweiner’s picture

Hooks and callbacks can load it from storage, though, if they are looping over $order->getItems() (whether $order is coming from the pane, the route, or wherever). The source of truth for a third-party is really the item on the order, not necessarily the donationItem in the pane, because the pane's donationItem is just a stored reference from when the pane is initialized.

I think this issue stems from modifying the the order item in validatePaneForm, but saving it in submitPaneForm, because it can lead to this flow:

  1. Checkout form is loaded and the order item is set on $this->donationItem
  2. User submits checkout form
  3. DonationItemPaneBase::validatePaneForm fires, which sets state on the local copy of the donation item
  4. Custom validation callback (like the one in the summary, attached to the checkout form) fires, which discovers the donation line item from the order itself, modifies it, and saves it. This updates the actual donation line item entity itself but not $this->donationItem in the pane's state (since it is a copy). Now there is a mismatch where the pane has a stale version of the item.
  5. submitPaneForm saves the stale, local version of the order item, which undoes any modifications done by the custom callback. It's like loading the order item entity, setting all values to the old ones, and then saving the entity.

In step 4, even if they were just to modify the $form_state, then step 5 would still be out of sync since it doesn't do any discovery on the of the $form_state. If the order item entity is deleted in step 4 instead of just modified, then you end up with the "cannot update entity ID during save" from step 5.

I will say that moving our logic from a #validate callback to a #submit callback avoids the problem. It may be accurate that a third-party callback should not modify+save the entity inside of #validate. But, if they do, it is awkward that the changes are tossed out without any notice or an error is thrown if the entity was deleted.

As far as loading the $order from the route, you're right that it is accessible when you're in the pane, but if you're in a custom callback attached to the checkout form itself then I'm not sure how to load it other than from the route. I don't think it's available in $form_state or the $form array, is it?

fathershawn’s picture

Good points all - I'm going to have a look at some other checkout panes, and core entity forms for some models.

if you're in a custom callback attached to the checkout form itself then I'm not sure how to load it other than from the route

The pane gets it from the flow. Does that work for your context? \Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface::getOrder

fathershawn’s picture

Status: Needs review » Postponed (maintainer needs more info)

Custom validation callback (like the one in the summary, attached to the checkout form) fires, which discovers the donation line item from the order itself, modifies it, and saves it. This updates the actual donation line item entity itself but not $this->donationItem in the pane's state (since it is a copy). Now there is a mismatch where the pane has a stale version of the item.

I don't see this in our code (commerce_donation_flow\Plugin\Commerce\CheckoutFlow\DonationCheckoutFlow::buildSummaries) nor in commerce_checkout\Plugin\Commerce\CheckoutPane\OrderSummary. I want to account for these cases in a refactor, so if you could point me to examples that would save time.

mrweiner’s picture

Right, it's not present in commerce_donation_flow code. Everything that I'm referring to is the result of us (as in me, on my own project, not us as in commerce_donation_flow) adding a callback on the checkout form in another module that modifies the donation item.

mrweiner’s picture

To clarify, when DonationItemPaneBase::create() does $instance->donationItem = reset($donations);, $instance->donationItem is now a copy of the entity. That's what I'm referring to by "stale". It is not affected by outside modifications to the donation item itself, because it's a copy of the object as it existed at instantiation.

mrweiner’s picture

The pane gets it from the flow. Does that work for your context? \Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface::getOrder

I don't think that the checkout flow is available from the callback, unless there's a service I'm missing that can fetch it. It's not on $form or $form_state directly.

fathershawn’s picture

There was an issue fork for #3243886: Alter method of getting the donation item - See MR there for an approach that solves that issue and this.

fathershawn’s picture

Status: Postponed (maintainer needs more info) » Closed (outdated)
fathershawn’s picture