diff --git a/README b/README index abcc1d7..7a72e23 100644 --- a/README +++ b/README @@ -5,13 +5,18 @@ ## Usage 1. Create a webform -2. Add a Price element -3. Setup the values of the element - 1. Use the price next to the values to simulate product variations - 2. Use the price below to create multiple products with one price -4. Save the webform and create a submission +2. Add elements + 1. Order ID (int) + 2. Order Url (url) + 3. Order status (string) + 4. Product (options) +3. Set permissions for 'Order *'-fields for only administrators. +4. Setup the values of the element Price + 1. Use the key to the option values to simulate product variations + 2. Use the top price below to create multiple products with one price +5. Setup the Webform Product Handler and map the fields +6. Create a link-field (no title) in the Order Type 'Webform' called 'field_link_order_origin'. +7. Save the webform and create a submission ## Known issues -* Will only work for the default store, if no store is selected as default, it will crash -* There is no reference to the webform submission in the order -* There is no reference to the order in the webform submission +Still work in progress, please report issues at https://github.com/chx/webform_product/issues diff --git a/config/install/commerce_order.commerce_order_item_type.webform.yml b/config/install/commerce_order.commerce_order_item_type.webform.yml index 94501b6..d274d1e 100644 --- a/config/install/commerce_order.commerce_order_item_type.webform.yml +++ b/config/install/commerce_order.commerce_order_item_type.webform.yml @@ -6,3 +6,4 @@ id: webform purchasableEntityType: '' orderType: default traits: { } +locked: false diff --git a/src/Controller/WebformProductController.php b/src/Controller/WebformProductController.php new file mode 100644 index 0000000..bec25a5 --- /dev/null +++ b/src/Controller/WebformProductController.php @@ -0,0 +1,240 @@ +getWebformSubmissionFromToken($webform); + $order = $this->getOrder(); + + $this->checkAccess($webform_submission, $order); + + self::setSubmissionOrderStatus($webform_submission, self::PAYMENT_STATUS_COMPLETED); + + // Disable the webform draft state, to mark the payment as completed. + // Set the webform 'completed' state, to trigger webform handlers such as + // Exact and Email. + $webform_submission + ->set('in_draft', FALSE) + ->set('completed', TRUE) + ->save(); + + // Transition the order from 'draft' to 'validation'. + $this->placeOrder($order); + + // Load confirmation page settings. + $confirmation_type = $webform_submission->getWebform()->getSetting('confirmation_type'); + $has_confirmation_url = in_array($confirmation_type, [WebformInterface::CONFIRMATION_URL, WebformInterface::CONFIRMATION_URL_MESSAGE]); + $has_confirmation_message = !in_array($confirmation_type, [WebformInterface::CONFIRMATION_URL]); + + $redirect_url = $webform_submission->getSourceUrl(); + if ($has_confirmation_url) { + // @todo Validate url like \Drupal\webform\WebformSubmissionForm::setConfirmation(). + $url = $webform_submission->getWebform()->getSetting('confirmation_url'); + if ($url) { + $redirect_url = URL::fromUserInput($url); + } + } + + if ($has_confirmation_message) { + $message = $webform_submission->getWebform()->getSetting('confirmation_message'); + $this->messenger()->addStatus(Xss::filter($message)); + } + + return $this->redirectToUrl($redirect_url); + } + + /** + * Cancel the submission and notify user. + * + * @param \Drupal\webform\WebformInterface $webform + * A webform. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * The Redirect response. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + public function canceledSubmission(WebformInterface $webform) { + $webform_submission = $this->getWebformSubmissionFromToken($webform); + + self::setSubmissionOrderStatus($webform_submission, self::PAYMENT_STATUS_CANCELED); + $webform_submission->resave(); + + $this->messenger()->addWarning(t('The payment has been canceled, please re-submit the form to complete the payment.')); + + return $this->redirectToUrl($webform_submission->getSourceUrl()); + } + + /** + * Cancel the submission, notify user and log the exception. + * + * @param \Drupal\webform\WebformInterface $webform + * A webform. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * The Redirect response. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + public function exceptionSubmission(WebformInterface $webform) { + $webform_submission = $this->getWebformSubmissionFromToken($webform); + + self::setSubmissionOrderStatus($webform_submission, self::PAYMENT_STATUS_EXCEPTION); + $webform_submission->resave(); + + $this->messenger()->addError(t('Something went wrong, the payment has been canceled. Please try again later.')); + + return $this->redirectToUrl($webform_submission->getSourceUrl()); + } + + /** + * Transition the order status to 'completed'. + * + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * A order. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + protected function placeOrder(OrderInterface $order) { + $transition = $order->getState()->getWorkflow()->getTransition('place'); + $order->getState()->applyTransition($transition); + + // The order is probably payed, allow editing by shop managers again. + $order->unlock(); + + $order->save(); + } + + /** + * Get the order from the current request. + * + * @return \Drupal\commerce_order\Entity\OrderInterface + * A order. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + protected function getOrder() { + $order_id = \Drupal::requestStack()->getCurrentRequest()->get('order'); + /** @var \Drupal\commerce_order\Entity\OrderInterface $order */ + $order = \Drupal::entityTypeManager() + ->getStorage('commerce_order') + ->load($order_id); + return $order; + } + + /** + * Get webform submission from query token. + * + * @param \Drupal\webform\WebformInterface $webform + * The webform, related to the token. + * + * @return \Drupal\webform\WebformSubmissionInterface|null + * A submission loaded from the token. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + protected function getWebformSubmissionFromToken(WebformInterface $webform) { + /** @var \Drupal\webform\WebformSubmissionStorageInterface $storage */ + $storage = \Drupal::entityTypeManager()->getStorage('webform_submission'); + + $token = \Drupal::requestStack()->getCurrentRequest()->get('submission'); + if (!$token) { + throw new AccessDeniedHttpException('Token not found.'); + } + + $webform_submission = $storage->loadFromToken($token, $webform); + if (!$webform_submission) { + throw new AccessDeniedHttpException('Webform submission failed to load.'); + } + + return $webform_submission; + } + + /** + * Check if the submission and order. + * + * @param \Drupal\webform\WebformSubmissionInterface $webform_submission + * A webform submission. + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * A order. + */ + protected function checkAccess(WebformSubmissionInterface $webform_submission, OrderInterface $order) { + if (!$order || !$webform_submission->isDraft() || $order->getState()->value == 'completed') { + throw new AccessDeniedHttpException('Submission already completed.'); + } + } + + /** + * Redirect to the given Url. + * + * @param \Drupal\Core\URL $url + * Url to redirect to. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * The Redirect response. + */ + protected function redirectToUrl(URL $url) { + return $this->redirect($url->getRouteName(), $url->getRouteParameters()); + } + + /** + * Set the Order status in the Submission. + * + * @param \Drupal\webform\WebformSubmissionInterface $webformSubmission + * The webform Submission. + * @param string $status + * The status to store in the Submission. + */ + public static function setSubmissionOrderStatus(WebformSubmissionInterface $webformSubmission, $status) { + $handlers = $webformSubmission->getWebform()->getHandlers('webform_product'); + + $config = $handlers->getConfiguration(); + /** @var \Drupal\webform\Plugin\WebformHandlerInterface $handler */ + $handler = reset($config); + $settings = $handler['settings']; + + if ($settings[WebformProductWebformHandler::FIELD_STATUS]) { + $webformSubmission->setElementData($settings[WebformProductWebformHandler::FIELD_STATUS], $status); + } + } + +} diff --git a/src/EventSubscriber/OrderEventSubscriber.php b/src/EventSubscriber/OrderEventSubscriber.php new file mode 100644 index 0000000..9dd1f52 --- /dev/null +++ b/src/EventSubscriber/OrderEventSubscriber.php @@ -0,0 +1,91 @@ + ['onOrderValidatePostTransition'], + ]; + return $events; + } + + /** + * Post Transition; Place (from Draft to Validation). + * + * Execute Webform Submission Handlers on Validate transition, when a payment + * has been validated by the payment provider. + * + * This will only be triggered if the submission is initialized. + * + * @todo Add validate state to the submission status field. + * @todo Make use of the workflow labels, instead of custom labels. + * + * @param \Drupal\state_machine\Event\WorkflowTransitionEvent $event + * The event. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function onOrderValidatePostTransition(WorkflowTransitionEvent $event) { + $order = $event->getEntity(); + + if (!$order->hasField(WebformProductWebformHandler::FIELD_LINK_ORDER_ORIGIN)) { + return; + } + + $source_uri = $order->get(WebformProductWebformHandler::FIELD_LINK_ORDER_ORIGIN)->getValue(); + $params = Url::fromUri($source_uri[0]['uri'])->getRouteParameters(); + + /** @var \Drupal\webform\WebformSubmissionInterface $webformSubmission */ + $webformSubmission = \Drupal::entityTypeManager()->getStorage('webform_submission')->load($params['webform_submission']); + $handlers = $webformSubmission->getWebform()->getHandlers('webform_product'); + + $config = $handlers->getConfiguration(); + if (!$config) { + return; + } + + /** @var \Drupal\webform\Plugin\WebformHandlerInterface $handler */ + $handler = reset($config); + $settings = $handler['settings']; + + $status = $webformSubmission->getElementData($settings[WebformProductWebformHandler::FIELD_STATUS]); + + // Complete submission if this hasn't been done. + // There is no need for an access check, because the transition will check. + if ($status && $status === WebformProductController::PAYMENT_STATUS_INITIALIZED) { + + // Disable the webform draft state, to mark the payment as completed. + // Set the webform 'completed' state, to trigger webform handlers such as + // Exact and Email. + $webformSubmission + ->setElementData($settings[WebformProductWebformHandler::FIELD_STATUS], WebformProductController::PAYMENT_STATUS_COMPLETED) + ->set('in_draft', FALSE) + ->set('completed', TRUE) + ->save(); + + $this->getLogger('webform_product')->notice('Finalized Webform Submission %sid on payment', [ + '%sid' => $webformSubmission->id(), + ]); + } + } + +} diff --git a/src/Plugin/WebformHandler/WebformProductWebformHandler.php b/src/Plugin/WebformHandler/WebformProductWebformHandler.php new file mode 100644 index 0000000..ddb72b5 --- /dev/null +++ b/src/Plugin/WebformHandler/WebformProductWebformHandler.php @@ -0,0 +1,785 @@ +entityTypeManager = $entity_type_manager; + $this->token = $token; + $this->tokenManager = $token_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('logger.factory'), + $container->get('config.factory'), + $container->get('entity_type.manager'), + $container->get('webform_submission.conditions_validator'), + $container->get('token'), + $container->get('webform.token_manager') + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + self::COMMERCE_STORE => NULL, + self::COMMERCE_ORDER_TYPE => self::DEFAULT_ORDER_TYPE, + self::COMMERCE_ORDER_ITEM_TITLE => self::DEFAULT_ORDER_ITEM_TITLE, + self::COMMERCE_ORDER_ITEM_TYPE => self::DEFAULT_ORDER_ITEM_TYPE, + 'route' => 'commerce_checkout.form', + self::COMMERCE_CHECKOUT_STEP => self::DEFAULT_CHECKOUT_STEP, + self::COMMERCE_GATEWAY => NULL, + self::COMMERCE_METHOD => NULL, + self::ORDER_PRICE => NULL, + self::FIELD_STATUS => NULL, + self::FIELD_ORDER_ID => NULL, + self::FIELD_ORDER_URL => NULL, + self::FIELD_TOTAL_PRICE => NULL, + ]; + } + + /** + * {@inheritdoc} + * + * @todo Create debug mode setting. + * @todo Create field mapping for Billing information (name, address & mail). + * @todo Create more route choices. + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $configuration = $this->getConfiguration(); + $settings = $configuration['settings']; + + $form['commerce'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Commerce'), + ]; + $form['commerce'][self::COMMERCE_STORE] = [ + '#type' => 'select', + '#title' => $this->t('Store'), + '#options' => $this->getEntityOptions('commerce_store'), + '#default_value' => $settings[self::COMMERCE_STORE], + '#required' => TRUE, + ]; + $form['commerce'][self::COMMERCE_ORDER_TYPE] = [ + '#type' => 'select', + '#title' => $this->t('Order type'), + '#options' => $this->getEntityOptions('commerce_order_type'), + '#default_value' => $settings[self::COMMERCE_ORDER_TYPE], + '#required' => TRUE, + ]; + $form['commerce'][self::COMMERCE_ORDER_ITEM_TYPE] = [ + '#type' => 'select', + '#title' => $this->t('Order item type'), + '#options' => $this->getEntityOptions('commerce_order_item_type'), + '#default_value' => $settings[self::COMMERCE_ORDER_ITEM_TYPE], + '#required' => TRUE, + ]; + $form['commerce'][self::COMMERCE_ORDER_ITEM_TITLE] = [ + '#type' => 'textfield', + '#title' => $this->t('Order item title'), + '#description' => $this->t('Default %default.', ['%default' => self::DEFAULT_ORDER_ITEM_TITLE]), + '#default_value' => $settings[self::COMMERCE_ORDER_ITEM_TITLE], + '#required' => TRUE, + ]; + $form['commerce'][self::COMMERCE_GATEWAY] = [ + '#type' => 'select', + '#title' => $this->t('Payment provider'), + '#options' => $this->getEntityOptions('commerce_payment_gateway', [ + 'status' => TRUE, + ]), + '#default_value' => $settings[self::COMMERCE_GATEWAY], + '#required' => TRUE, + ]; + + $token_types = ['webform', 'webform_submission']; + // Show webform role tokens if they have been specified. + if (!empty($roles_element_options)) { + $token_types[] = 'webform_role'; + } + $form['commerce']['token_tree_link'] = $this->tokenManager->buildTreeLink( + $token_types, + $this->t('Use [webform_submission:values:ELEMENT_KEY:raw] to get plain text values.') + ); + + $form['order_data'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Data to create order'), + ]; + $form['order_data']['info'] = [ + '#markup' => '

' . $this->t('Use this price field as the price of a single order item. Leave it empty to use individual webform elements with a Price field, where one order item is created per form element.') . '

', + ]; + + $field_types = ['number', 'numeric', 'textfield', 'webform_computed_twig']; + $form['order_data'][self::ORDER_PRICE] = [ + '#type' => 'select', + '#title' => $this->t('Total price'), + '#options' => $this->getElementsSelectOptions($field_types), + '#default_value' => $settings[self::ORDER_PRICE], + '#empty_value' => '', + '#required' => FALSE, + '#description' => $this->t('Field types allowed: @types.', ['@types' => implode(', ', $field_types)]), + ]; + + $form['field_mapping'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Field mapping'), + ]; + + $field_types = ['textfield']; + $form['field_mapping'][self::FIELD_STATUS] = [ + '#type' => 'select', + '#title' => $this->t('Payment status'), + '#options' => $this->getElementsSelectOptions($field_types), + '#default_value' => $settings[self::FIELD_STATUS], + '#empty_value' => '', + '#required' => TRUE, + '#description' => $this->t('Field types allowed: @types.', ['@types' => implode(', ', $field_types)]), + ]; + + $field_types = ['number', 'numeric', 'textfield']; + $form['field_mapping'][self::FIELD_ORDER_ID] = [ + '#type' => 'select', + '#title' => $this->t('Order ID'), + '#options' => $this->getElementsSelectOptions($field_types), + '#default_value' => $settings[self::FIELD_ORDER_ID], + '#empty_value' => '', + '#required' => TRUE, + '#description' => $this->t('Field types allowed: @types.', ['@types' => implode(', ', $field_types)]), + ]; + + $field_types = ['url']; + $form['field_mapping'][self::FIELD_ORDER_URL] = [ + '#type' => 'select', + '#title' => $this->t('Order URL'), + '#options' => $this->getElementsSelectOptions($field_types), + '#default_value' => $settings[self::FIELD_ORDER_URL], + '#empty_value' => '', + '#required' => TRUE, + '#description' => $this->t('Field types allowed: @types.', ['@types' => implode(', ', $field_types)]), + ]; + + $field_types = ['number', 'numeric', 'textfield']; + $form['field_mapping'][self::FIELD_TOTAL_PRICE] = [ + '#type' => 'select', + '#title' => $this->t('Total price'), + '#options' => $this->getElementsSelectOptions($field_types), + '#default_value' => $settings[self::FIELD_TOTAL_PRICE], + '#empty_value' => '', + '#required' => FALSE, + '#description' => $this->t('Field types allowed: @types.', ['@types' => implode(', ', $field_types)]) . '
' . $this->t('Use this if you want to safe the total order amount to a specific field.'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + * + * @todo Set mapped webform order and payment field permissions to 'view-only'. + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + parent::applyFormStateToConfiguration($form_state); + + $values = $form_state->getValues(); + + foreach ($values['commerce'] as $key => $value) { + $this->configuration[$key] = $value; + } + + foreach ($values['order_data'] as $key => $value) { + $this->configuration[$key] = $value; + } + + foreach ($values['order_result'] as $key => $value) { + $this->configuration[$key] = $value; + } + } + + /** + * {@inheritdoc} + */ + public function postSave(WebformSubmissionInterface $webform_submission, $update = TRUE) { + if ($update == TRUE) { + return; + } + + try { + $orderItems = $this->getOrderItems($webform_submission); + if (empty($orderItems)) { + return; + } + + /** @var \Drupal\commerce_cart\CartProviderInterface $cartProvider */ + $cartProvider = \Drupal::service('commerce_cart.cart_provider'); + /** @var \Drupal\commerce_order\Entity\OrderInterface $cartOrder */ + $cartOrder = $this->getCart($cartProvider, $this->getStore(), TRUE); + + // Fill the Cart. + foreach ($orderItems as $orderItem) { + $orderItem->save(); + $cartOrder->addItem($orderItem); + } + $cartOrder->save(); + $cartOrder = $this->entityTypeManager->getStorage('commerce_order')->load($cartOrder->id()); + + // Save the Cart (Order) with Submission data. + $this->setOrderCheckoutProcess($cartOrder); + $this->setOrderLinkReference($cartOrder, $webform_submission); + $this->setOrderCustomer($cartOrder); + + // Save the submission with Cart data. + $this->setSubmissionTotalPrice($webform_submission, $cartOrder); + WebformProductController::setSubmissionOrderStatus($webform_submission, WebformProductController::PAYMENT_STATUS_INITIALIZED); + $this->setSubmissionOrderReference($webform_submission, $cartOrder); + $webform_submission->set('in_draft', TRUE); + $webform_submission->resave(); + + // Protect order from adding new products. + $cartOrder->lock(); + + $cartOrder->save(); + + // Reload the order. + $cartOrder = $this->entityTypeManager->getStorage('commerce_order')->load($cartOrder->id()); + + $this->redirectToCheckout($cartOrder); + } + catch (\Exception $e) { + $this->loggerFactory->get($this->pluginId)->error($e->getMessage()); + } + } + + /** + * Get option list of Entities. + * + * @param string $entity_type + * The entity type to load. + * @param array $properties + * The loaded entity condtions. + * + * @return array + * List with ids as key and label as value. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + protected function getEntityOptions($entity_type, array $properties = []) { + $options = []; + + /** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface[] $payment_gateways */ + $entities = $this->entityTypeManager + ->getStorage($entity_type) + ->loadByProperties($properties); + + foreach ($entities as $entity) { + $options[$entity->id()] = $entity->label(); + } + + return $options; + } + + /** + * Gather all Order Items from the webform Submission. + * + * @param \Drupal\webform\WebformSubmissionInterface $webformSubmission + * The webform submission. + * + * @return \Drupal\commerce_order\Entity\OrderItemInterface[] + * List of Order Items. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + protected function getOrderItems(WebformSubmissionInterface $webformSubmission) { + $price_fields = $this->getWebform()->getThirdPartySettings($this->pluginId); + + // No prices, no Order. + if (!$price_fields) { + return []; + } + + $payment_status = $this->getSavedPaymentStatus($webformSubmission); + + // Create only an order for new webform submissions. + if ($payment_status != WebformProductController::PAYMENT_STATUS_NULL) { + return []; + } + + /** @var \Drupal\commerce_store\Entity\StoreInterface $store */ + $store = $this->getStore(); + $currencyCode = $store->getDefaultCurrency()->getCurrencyCode(); + + $orderItems = []; + $configuration = $this->getConfiguration(); + $settings = $configuration['settings']; + + // @todo Make this also available for multiple elements. + $order_item_title = $this->tokenManager->replace($settings[self::COMMERCE_ORDER_ITEM_TITLE], $webformSubmission, [ + 'webform' => $this->getWebform(), + ]); + + if ($this->useElementBasedOrder()) { + // Create Order Item for each: + // - element option with a price. + // - element with a top price. + foreach ($webformSubmission->getData() as $key => $value) { + if (empty($price_fields[$key])) { + continue; + } + + // Element with 'top'. + if (!empty($price_fields[$key]['top'])) { + $orderItems[] = OrderItem::create([ + 'type' => $this->configuration[self::COMMERCE_ORDER_ITEM_TYPE], + 'title' => $order_item_title, + 'quantity' => 1, + 'unit_price' => [ + 'number' => $price_fields[$key]['top'], + 'currency_code' => $currencyCode, + ], + ]); + } + + if (!empty($price_fields[$key]['options'])) { + // Fix for when value is not an array. + if (!is_array($value)) { + $value_to_validate = [$value]; + } + else { + $value_to_validate = $value; + } + + $options = array_keys($price_fields[$key]['options']); + $price_options = array_intersect($value_to_validate, $options); + $has_other = $this->getWebform()->getElement($key); + + // Other values. + if (isset($has_other['#other_type']) && $has_other['#other__type'] == 'number' && empty($price_options)) { + $orderItems[] = OrderItem::create([ + 'type' => $this->configuration[self::COMMERCE_ORDER_ITEM_TYPE], + 'title' => $order_item_title, + 'quantity' => 1, + 'unit_price' => [ + 'number' => $value, + 'currency_code' => $currencyCode, + ], + ]); + } + else { + // Option elements with price as option (checkboxes or radios). + foreach ($price_options as $option) { + $orderItems[] = OrderItem::create([ + 'type' => $this->configuration[self::COMMERCE_ORDER_ITEM_TYPE], + 'title' => $order_item_title, + 'quantity' => 1, + 'unit_price' => [ + 'number' => $price_fields[$key]['options'][$option], + 'currency_code' => $currencyCode, + ], + ]); + } + } + } + } + } + else { + $orderItems = []; + $price = $this->formatPrice($webformSubmission->getElementData($settings[self::ORDER_PRICE])); + if ($price > 0) { + $orderItems[] = OrderItem::create([ + 'type' => $this->configuration[self::COMMERCE_ORDER_ITEM_TYPE], + 'title' => $order_item_title, + 'quantity' => 1, + 'unit_price' => [ + 'number' => $price, + 'currency_code' => $currencyCode, + ], + ]); + } + } + + return $orderItems; + } + + /** + * Determine if Element Based order must be used. + * + * The commerce order is either created with one order item per priced field + * or with one item based on a single field value. The latter is usually a + * calculated value or the result of a (if/else) condition. + * + * @return bool + * Returns true if element based orders are used. + */ + private function useElementBasedOrder() { + return empty($this->configuration[self::ORDER_PRICE]); + } + + /** + * Get webform elements selectors as options. + * + * @param array $types + * List of types to filter. + * - Leave empty skip filtering of types. + * + * @see \Drupal\webform\Entity\Webform::getElementsSelectorOptions() + * + * @return array + * Webform elements selectors as options. + */ + private function getElementsSelectOptions(array $types = []) { + $options = []; + $elements = $this->getWebform()->getElementsInitializedAndFlattened(); + foreach ($elements as $key => $element) { + // Skip element if not in given 'types' array. + if ($types && !in_array($element['#type'], $types)) { + continue; + } + + $options[$key] = $element['#title']; + } + return $options; + } + + /** + * Get the payment status of the submission. + * + * - Nothing if there isn't any payment at all. + * - Initilized for started, but not completed payments. + * - Canceled for payments canceled by the user. + * - Exception for payments canceled by the provider. + * + * @param \Drupal\webform\WebformSubmissionInterface $webformSubmission + * The webform submission. + * + * @return string + * The status of the payment. + * + * @see \Drupal\webform_product\Plugin\WebformHandler\WebformProductWebformHandler::PAYMENT_STATUS_NULL; + * @see \Drupal\webform_product\Plugin\WebformHandler\WebformProductWebformHandler::PAYMENT_STATUS_INITIALIZED;\ + * @see \Drupal\webform_product\Plugin\WebformHandler\WebformProductWebformHandler::PAYMENT_STATUS_CANCELED; + * @see \Drupal\webform_product\Plugin\WebformHandler\WebformProductWebformHandler::PAYMENT_STATUS_COMPLETED; + * @see \Drupal\webform_product\Plugin\WebformHandler\WebformProductWebformHandler::PAYMENT_STATUS_EXCEPTION; + */ + private function getSavedPaymentStatus(WebformSubmissionInterface $webformSubmission) { + $value = $webformSubmission->getElementData($this->configuration[self::FIELD_STATUS]); + + return $value; + } + + /** + * Set total price of the Order in the Submission. + * + * @param \Drupal\webform\WebformSubmissionInterface $webformSubmission + * The webform Submission. + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * The Order. + */ + protected function setSubmissionTotalPrice(WebformSubmissionInterface $webformSubmission, OrderInterface $order) { + // Save Total price of order. + if ($this->configuration[self::FIELD_TOTAL_PRICE]) { + $total = $order->getTotalPrice()->getNumber(); + $webformSubmission->setElementData($this->configuration[self::FIELD_TOTAL_PRICE], $total); + } + } + + /** + * Set the Order reference in the webform Submission. + * + * @param \Drupal\webform\WebformSubmissionInterface $webformSubmission + * The webform Submission. + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * The Order. + * + * @throws \Drupal\Core\Entity\EntityMalformedException + */ + protected function setSubmissionOrderReference(WebformSubmissionInterface $webformSubmission, OrderInterface $order) { + // Save order id to the webform for back reference. + if ($this->configuration[self::FIELD_ORDER_ID]) { + $webformSubmission + ->setElementData($this->configuration[self::FIELD_ORDER_ID], $order->id()); + } + if ($this->configuration[self::FIELD_ORDER_URL]) { + $order_url = $order->toUrl()->toString(); + $webformSubmission + ->setElementData($this->configuration[self::FIELD_ORDER_URL], $order_url); + } + } + + /** + * Redirect to the configured checkout step in the Checkout Flow. + * + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * The Order. + */ + protected function redirectToCheckout(OrderInterface $order) { + // Redirect to checkout process. + $response = new RedirectResponse(Url::fromRoute($this->configuration['route'], [ + 'commerce_order' => $order->id(), + 'step' => $this->configuration[self::COMMERCE_CHECKOUT_STEP], + ])->toString()); + + $request = \Drupal::request(); + // Save the session. + $request->getSession()->save(); + $response->prepare($request); + // Trigger kernel events. + \Drupal::service('kernel')->terminate($request, $response); + + $response->send(); + exit(); + } + + /** + * Set Customer data for Order. + * + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * The Order. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * + * @todo Create full commerce profile for order with address and mail info. + */ + protected function setOrderCustomer(OrderInterface $order) { + $billing_profile = Profile::create([ + 'uid' => 0, + 'type' => 'customer', + ]); + $billing_profile->save(); + + // Add profile information. + $order->setBillingProfile($billing_profile); + } + + /** + * Save back reference to the webform as link. + * + * Order info can't be referenced, if the referenced entity doesn't have the + * same lifespan. + * + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * The Order. + * @param \Drupal\webform\WebformSubmissionInterface $webformSubmission + * The webform submission. + * + * @todo Make field FIELD_LINK_ORDER_ORIGIN configurable. + * + * @throws \Drupal\Core\Entity\EntityMalformedException + */ + protected function setOrderLinkReference(OrderInterface $order, WebformSubmissionInterface $webformSubmission) { + if ($order->hasField(self::FIELD_LINK_ORDER_ORIGIN)) { + $uri = $webformSubmission->toUrl()->toUriString(); + $order->set(self::FIELD_LINK_ORDER_ORIGIN, $uri); + } + } + + /** + * Set Checkout Process variables for Order. + * + * @param \Drupal\commerce_order\Entity\OrderInterface $order + * The Order. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + protected function setOrderCheckoutProcess(OrderInterface $order) { + /** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */ + $payment_gateway = $this->entityTypeManager->getStorage('commerce_payment_gateway')->load($this->configuration[self::COMMERCE_GATEWAY]); + + if (!$payment_gateway) { + $this->loggerFactory->get($this->pluginId)->error(t('Failed to get a Payment Gateway')); + return; + } + + $payment_method = empty($this->configuration[self::COMMERCE_METHOD]) ? NULL : $this->configuration[self::COMMERCE_METHOD]; + + // Save additional info to the order to speedup the checkout progress. + $order + ->set(self::COMMERCE_CHECKOUT_STEP, $this->configuration[self::COMMERCE_CHECKOUT_STEP]) + ->set(self::COMMERCE_GATEWAY, $payment_gateway->id()) + ->set(self::COMMERCE_METHOD, $payment_method); + } + + /** + * Get a Cart (Order) for the current user. + * + * Can be a new or existing cart. + * + * @param \Drupal\commerce_cart\CartProviderInterface $cartProvider + * The Cart Provider. + * @param \Drupal\commerce_store\Entity\StoreInterface $store + * The Store. + * @param bool $remove_existing_items + * Flag to remove existing items from the Cart. + * + * @return \Drupal\commerce_order\Entity\OrderInterface|null + * Cart of current user. + */ + protected function getCart(CartProviderInterface $cartProvider, StoreInterface $store, $remove_existing_items = TRUE) { + $order_type = $this->configuration[self::COMMERCE_ORDER_TYPE]; + + /** @var \Drupal\commerce_order\Entity\OrderInterface $order */ + $order = $cartProvider->getCart($order_type, $store) ?: $cartProvider->createCart($order_type, $store); + + if (!$order) { + $this->loggerFactory->get($this->pluginId)->error(t('Failed to get a Cart Order')); + return NULL; + } + + if ($remove_existing_items && $order->hasItems()) { + foreach ($order->getItems() as $item) { + $order->removeItem($item); + } + } + + return $order; + } + + /** + * Get the selected store. + * + * @return \Drupal\commerce_store\Entity\StoreInterface + * The Store. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + */ + protected function getStore() { + /** @var \Drupal\commerce_store\Entity\StoreInterface $store */ + $store = $this->entityTypeManager->getStorage('commerce_store') + ->load($this->configuration[self::COMMERCE_STORE]); + + if (!$store) { + $this->loggerFactory->get($this->pluginId)->error(t('Failed to get a Store')); + return NULL; + } + + return $store; + } + + /** + * Format the price value. + * + * We allow various field types as price input. This converts them to a float + * value. + * + * @param mixed $value + * Raw price value. + * + * @return float + * Converted value. + */ + private function formatPrice($value) { + // Convert Computed Twig. + if ($value instanceof MarkupInterface) { + $value = (string) $value; + $value = preg_replace('/[\n\r\t]/', '', $value); + } + // Convert text. + $value = (string) $value; + $value = trim($value); + $value = str_replace(',', '.', str_replace('.', '', $value)); + $value = empty($value) ? '0' : $value; + if (!is_numeric($value)) { + throw new WebformException($this->t('Can not make price from %value.', ['%value' => $value])); + } + + return $value; + } + +} diff --git a/src/Plugin/webform_product/WebformOptions.php b/src/Plugin/webform_product/WebformOptions.php index 763a6fe..e0e909a 100644 --- a/src/Plugin/webform_product/WebformOptions.php +++ b/src/Plugin/webform_product/WebformOptions.php @@ -12,18 +12,19 @@ class WebformOptions { public static function process(&$element, FormStateInterface $form_state) { - // Check for price_* elements, skip the check for Option definitions. - if (method_exists($form_state->getFormObject(), 'getElement')) { - $element_info = $form_state->getFormObject()->getElement(); - - // Only change the form of price_* webform elements. - if (strpos($element_info['#type'], 'price_', 0) === FALSE) { - return $element; - } - } - else { - return $element; - } + //@todo fix this when Price* webform elements are working. +// // Check for price_* elements, skip the check for Option definitions. +// if (method_exists($form_state->getFormObject(), 'getElement')) { +// $element_info = $form_state->getFormObject()->getElement(); +// +// // Only change the form of price_* webform elements. +// if (strpos($element_info['#type'], 'price_', 0) === FALSE) { +// return $element; +// } +// } +// else { +// return $element; +// } $element['options']['#element']['price'] = [ '#type' => 'textfield', @@ -39,6 +40,7 @@ public static function process(&$element, FormStateInterface $form_state) { } } } + // WebFormOptions::convertValuesToOptions() destroys our values so do // something about that. array_unshift($element['#element_validate'], [get_class(), 'convertToSettings']); diff --git a/src/WebFormProductFormHelper.php b/src/WebFormProductFormHelper.php index 2e775b4..4c105c5 100644 --- a/src/WebFormProductFormHelper.php +++ b/src/WebFormProductFormHelper.php @@ -4,18 +4,21 @@ use Drupal\commerce_order\Entity\OrderItem; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Url; use Drupal\webform\WebformInterface; use Drupal\webform\WebformSubmissionInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; class WebFormProductFormHelper { public static function processElementForm(&$element, FormStateInterface $form_state, &$complete_form) { $element_info = $form_state->getFormObject()->getElement(); - // Only change the form of price_* webform elements. - if (strpos($element_info['#type'], 'price_', 0) === FALSE) { - return $element; - } + //@todo fix this when Price* webform elements are working. +// // Only change the form of price_* webform elements. +// if (strpos($element_info['#type'], 'price_', 0) === FALSE) { +// return $element; +// } $element['price'] = [ '#type' => 'textfield', @@ -24,6 +27,7 @@ public static function processElementForm(&$element, FormStateInterface $form_st '#maxlength' => 20, '#default_value' => static::getSetting($form_state, 'top'), '#element_validate' => [[get_class(), 'saveTopPrice']], + '#description' => t('Use this to add an extra order item to the order, this can be used as a supplement with the options or single without the prices of the options.'), ]; return $element; } @@ -55,60 +59,6 @@ public static function setSetting(FormStateInterface $form_state, $settingKey, $ } } - public static function submissionToCart(array &$form, FormStateInterface $form_state) { - /** @var \Drupal\webform\WebformSubmissionInterface $submission */ - $submission = $form_state->getFormObject()->getEntity(); - if (!$submission instanceof WebformSubmissionInterface) { - return; - } - - $webform = $submission->getWebform(); - if (!$prices = $webform->getThirdPartySettings('webform_product')) { - return; - } - - /** @var \Drupal\commerce_store\Entity\StoreInterface $store */ - $store = \Drupal::service('commerce_store.current_store')->getStore(); - $currencyCode = $store->getDefaultCurrency()->getCurrencyCode(); - /** @var \Drupal\commerce_cart\CartProviderInterface $cartProvider */ - $cartProvider = \Drupal::service('commerce_cart.cart_provider'); - /** @var \Drupal\commerce_order\Entity\OrderInterface $cartOrder */ - $cartOrder = $cartProvider->getCart('default', $store) ?: $cartProvider->createCart('default', $store); - $elements = $webform->getElementsInitializedAndFlattened(); - $prices = $webform->getThirdPartySettings('webform_product'); - foreach ($submission->getData() as $key => $value) { - if (isset($prices[$key])) { - if (!empty($prices[$key]['top'])) { - $orderItem = OrderItem::create([ - 'type' => 'webform', - 'title' => $elements[$key]['#title'], - 'unit_price' => ['number' => $prices[$key]['top'], 'currency_code' => $currencyCode] - ]); - $orderItem->save(); - $cartOrder->addItem($orderItem); - } - if (!empty($prices[$key]['options'])) { - // Fix for when value is not an array. - if (!is_array($value)) { - $value = [$value]; - } - - foreach (array_intersect($value, array_keys($prices[$key]['options'])) as $option) { - $orderItem = OrderItem::create([ - 'type' => 'webform', - 'title' => $elements[$key]['#options'][$option], - 'unit_price' => ['number' => $prices[$key]['options'][$option], 'currency_code' => $currencyCode] - ]); - $orderItem->save(); - $cartOrder->addItem($orderItem); - } - } - } - } - $cartOrder->save(); - $form_state->setRedirect('commerce_cart.page'); - } - private static function getWebformInFormState(FormStateInterface $form_state) { /** @var \Drupal\webform_ui\Form\WebformUiElementEditForm $formObject */ $formObject = $form_state->getFormObject(); @@ -117,4 +67,5 @@ private static function getWebformInFormState(FormStateInterface $form_state) { return ($webformObject instanceof WebformInterface) ? $webformObject : NULL; } + } diff --git a/src/WebformProductPluginManager.php b/src/WebformProductPluginManager.php index 5162bf3..d41ab47 100644 --- a/src/WebformProductPluginManager.php +++ b/src/WebformProductPluginManager.php @@ -7,8 +7,16 @@ use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Plugin\DefaultPluginManager; +/** + * Class WebformProductPluginManager. + * + * @package Drupal\webform_product + */ class WebformProductPluginManager extends DefaultPluginManager { + /** + * {@inheritdoc} + */ public function __construct(\Traversable $namespaces, CacheBackendInterface $cacheBackend, ModuleHandlerInterface $module_handler, $plugin_interface = NULL, $plugin_definition_annotation_name = 'Drupal\Component\Annotation\Plugin', $additional_annotation_namespaces = []) { parent::__construct('Plugin/webform_product', $namespaces, $module_handler, NULL, PluginID::class); $this->setCacheBackend($cacheBackend, 'webform_product'); diff --git a/templates/webform-handler-webform-product-summary.html.twig b/templates/webform-handler-webform-product-summary.html.twig new file mode 100644 index 0000000..b63c414 --- /dev/null +++ b/templates/webform-handler-webform-product-summary.html.twig @@ -0,0 +1,19 @@ +{# +/** + * @file + * Default theme implementation for a summary of a webform action handler. + * + * Available variables: + * - settings: The current configuration for this debug handler. + * - handler: The action handler. + * + * @ingroup themeable + */ +#} + +{% if settings.store is not null %}{{ 'Store:'|t }} {{ settings.store }}
{% endif %} +{% if settings.order_type %}{{ 'Order type:'|t }} {{ settings.order_type }}
{% endif %} +{% if settings.checkout_step %}{{ 'Checkout step:'|t }} {{ settings.checkout_step }}
{% endif %} +{% if settings.payment_gateway %}{{ 'Payment gateway:'|t }} {{ settings.payment_gateway }}
{% endif %} +{% if settings.payment_method %}{{ 'Payment method:'|t }} {{ settings.payment_method }}
{% endif %} + diff --git a/webform_product.info.yml b/webform_product.info.yml index 8866b9c..83c39a0 100644 --- a/webform_product.info.yml +++ b/webform_product.info.yml @@ -3,6 +3,8 @@ type: module description: 'Setup a single webform as a product for Commerce.' package: Webform core: 8.x +interface translation project: webform_product +interface translation server pattern: modules/custom/%project/translations/%project.%language.po dependencies: - 'webform:webform' - 'commerce:commerce' diff --git a/webform_product.module b/webform_product.module index 2d38622..1254387 100644 --- a/webform_product.module +++ b/webform_product.module @@ -1,7 +1,15 @@ $definition) { /** @var \Drupal\Component\Plugin\PluginManagerInterface $pluginManager */ if (isset($info[$id])) { - $info[$id]['#process'][] = [DefaultFactory::getPluginClass($id, $definition), 'process']; + $info[$id]['#process'][] = [ + DefaultFactory::getPluginClass($id, $definition), + 'process', + ]; } } } @@ -25,8 +36,83 @@ function webform_product_form_webform_ui_element_form_alter(&$form, FormStateInt } /** - * Implements hook_webform_submission_form_alter(). + * Implements hook_theme(). + */ +function webform_product_theme() { + return [ + 'webform_handler_webform_product_summary' => [ + 'variables' => ['settings' => NULL, 'handler' => NULL], + ], + ]; +} + +/** + * Implements hook_form_FORM_ID_alter() for commerce_checkout_flow_multistep_default. + * + * @todo Make it work for onsite payments. */ -function webform_product_webform_submission_form_alter(array &$form, FormStateInterface $form_state, $form_id) { - $form['actions']['submit']['#submit'][] = [WebFormProductFormHelper::class, 'submissionToCart']; +function webform_product_form_commerce_checkout_flow_multistep_default_alter(&$form, FormStateInterface $form_state, $form_id) { + $offsite = NULL; + $payment = NULL; + if (isset($form['payment_process']['offsite_payment'])) { + $offsite = &$form['payment_process']['offsite_payment']; + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = $offsite['#default_value']; + } + + if (!$offsite || !$payment) { + return; + } + + $order = $payment->getOrder(); + if (!$order || !$order->hasField(WebformProductWebformHandler::FIELD_LINK_ORDER_ORIGIN)) { + return; + } + + // Load the webform submission from the saved link on the order. + $source_uri = $payment->getOrder()->get(WebformProductWebformHandler::FIELD_LINK_ORDER_ORIGIN)->getValue(); + $params = Url::fromUri($source_uri[0]['uri'])->getRouteParameters(); + + /** @var \Drupal\webform\WebformSubmissionInterface $webformSubmission */ + $webformSubmission = \Drupal::entityTypeManager()->getStorage('webform_submission')->load($params['webform_submission']); + + $options = [ + 'query' => [ + 'submission' => $webformSubmission->getToken(), + ], + 'absolute' => TRUE, + ]; + + $webform_id = $webformSubmission->getWebform()->id(); + + $offsite['#return_url'] = Url::fromRoute('webform_product.payment.completed', ['webform' => $webform_id, 'order' => $order->id()], $options)->toString(); + $offsite['#cancel_url'] = Url::fromRoute('webform_product.payment.canceled', ['webform' => $webform_id, 'order' => $order->id()], $options)->toString(); + $offsite['#exception_url'] = Url::fromRoute('webform_product.payment.exception', ['webform' => $webform_id, 'order' => $order->id()], $options)->toString(); +} + +/** + * Implements hook_form_FORM_ID_alter() for webform_settings_confirmation_form. + * + * Remove not implemented confirmation types, + * if the webform_product handler is used. + */ +function webform_product_form_webform_settings_confirmation_form_alter(&$form, FormStateInterface $form_state) { + /** @var \Drupal\webform\WebformInterface $webform */ + $webform = $form_state->getFormObject()->getEntity(); + + $handlers = $webform->getHandlers('webform_product', TRUE); + if ($handlers->count() == 0) { + return; + } + + foreach ($form['confirmation_type']['confirmation_type']['#options'] as $key => $option) { + switch ($key) { + case WebformInterface::CONFIRMATION_URL: + case WebformInterface::CONFIRMATION_URL_MESSAGE: + break; + + default: + unset($form['confirmation_type']['confirmation_type']['#options'][$key]); + } + } } diff --git a/webform_product.routing.yml b/webform_product.routing.yml new file mode 100644 index 0000000..80cdc57 --- /dev/null +++ b/webform_product.routing.yml @@ -0,0 +1,20 @@ +webform_product.payment.completed: + path: '/webform-product/{webform}/{order}/completed' + defaults: + _controller: '\Drupal\webform_product\Controller\WebformProductController::completedSubmission' + requirements: + _access: 'TRUE' + +webform_product.payment.canceled: + path: '/webform-product/{webform}/{order}/canceled' + defaults: + _controller: '\Drupal\webform_product\Controller\WebformProductController::canceledSubmission' + requirements: + _access: 'TRUE' + +webform_product.payment.exception: + path: '/webform-product/{webform}/{order}/exception' + defaults: + _controller: '\Drupal\webform_product\Controller\WebformProductController::exceptionSubmission' + requirements: + _access: 'TRUE' diff --git a/webform_product.services.yml b/webform_product.services.yml index b9742d8..b90b414 100644 --- a/webform_product.services.yml +++ b/webform_product.services.yml @@ -2,4 +2,7 @@ services: plugin.manager.webform_product: class: Drupal\webform_product\WebformProductPluginManager parent: default_plugin_manager - + webform_product.order_subscriber: + class: Drupal\webform_product\EventSubscriber\OrderEventSubscriber + tags: + - { name: event_subscriber }