diff --git a/modules/payment/commerce_payment.module b/modules/payment/commerce_payment.module index e1db90fe3b..09c5dc0f6e 100755 --- a/modules/payment/commerce_payment.module +++ b/modules/payment/commerce_payment.module @@ -93,6 +93,8 @@ function template_preprocess_commerce_payment_method(array &$variables) { '#markup' => $payment_method->label(), ], ]; + $expires = $payment_method->getExpiresTime(); + $variables['payment_method_expires'] = $expires ? date('n/Y', $expires) : t('Never'); foreach (Element::children($variables['elements']) as $key) { $variables['payment_method'][$key] = $variables['elements'][$key]; } diff --git a/modules/payment/commerce_payment.workflows.yml b/modules/payment/commerce_payment.workflows.yml index f7d8fb42de..f9be3498ec 100644 --- a/modules/payment/commerce_payment.workflows.yml +++ b/modules/payment/commerce_payment.workflows.yml @@ -48,3 +48,48 @@ payment_default: label: 'Refund payment' from: [capture_completed, capture_partially_refunded] to: capture_refunded + +payment_manual: + id: payment_manual + group: commerce_payment + label: 'Manual' + states: + new: + label: 'New' + pending: + label: 'Pending' + completed: + label: 'Completed' + refunded: + label: 'Refunded' + partially_refunded: + label: 'Partially refunded' + expired: + label: 'Expired' + canceled: + label: 'Canceled' + transitions: + pending_payment: + label: 'Pending payment' + from: [new] + to: pending + cancel: + label: 'Cancel payment' + from: [pending] + to: canceled + expire: + label: 'Expire payment' + from: [pending] + to: expired + payment_received: + label: 'Payment received' + from: [pending] + to: completed + partially_refund: + label: 'Partially refund payment' + from: [completed] + to: partially_refunded + refund: + label: 'Refund payment' + from: [completed, partially_refunded] + to: refunded diff --git a/modules/payment/config/schema/commerce_payment.schema.yml b/modules/payment/config/schema/commerce_payment.schema.yml index 24881a5b24..d1dedb6ad3 100644 --- a/modules/payment/config/schema/commerce_payment.schema.yml +++ b/modules/payment/config/schema/commerce_payment.schema.yml @@ -20,6 +20,20 @@ commerce_payment.commerce_payment_gateway.*: commerce_payment.commerce_payment_gateway.plugin.*: type: commerce_payment_gateway_configuration +commerce_payment.commerce_payment_gateway.plugin.manual: + type: commerce_payment_gateway_configuration + mapping: + reusable: + type: boolean + label: 'Reusable' + expires: + type: string + label: 'Expires' + instructions: + type: text_format + label: 'Instructions' + translatable: true + commerce_payment_gateway_configuration: type: mapping mapping: diff --git a/modules/payment/src/PaymentMethodStorage.php b/modules/payment/src/PaymentMethodStorage.php index 76b6fc4c72..ec300d5eb9 100644 --- a/modules/payment/src/PaymentMethodStorage.php +++ b/modules/payment/src/PaymentMethodStorage.php @@ -82,8 +82,11 @@ public function loadReusable(UserInterface $account, PaymentGatewayInterface $pa $query = $this->getQuery() ->condition('uid', $account->id()) ->condition('payment_gateway', $payment_gateway->id()) - ->condition('reusable', TRUE) + ->condition('reusable', TRUE); + $group = $query->orConditionGroup() ->condition('expires', $this->time->getRequestTime(), '>') + ->condition('expires', 0); + $query->condition($group) ->sort('created', 'DESC'); $result = $query->execute(); if (empty($result)) { diff --git a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php index e0121a344b..bdc344bd4d 100644 --- a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php +++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInformation.php @@ -228,11 +228,11 @@ protected function buildPaymentMethodOptions(array $payment_gateways) { $payment_method_types = $payment_gateway_plugin->getPaymentMethodTypes(); foreach ($payment_method_types as $payment_method_type_id => $payment_method_type) { $option_id = 'new--' . $payment_method_type_id . '--' . $payment_gateway->id(); - $option_label = $payment_method_type->getCreateLabel(); + $option_label = $payment_method_type->getCreateLabel($payment_gateway); if ($payment_method_type_counts[$payment_method_type_id] > 1) { // Append the payment gateway label to avoid duplicate labels. $option_label = $this->t('@payment_method_label (@payment_gateway_label)', [ - '@payment_method_label' => $payment_method_type->getCreateLabel(), + '@payment_method_label' => $payment_method_type->getCreateLabel($payment_gateway), '@payment_gateway_label' => $payment_gateway_plugin->getDisplayLabel(), ]); } diff --git a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInstructions.php b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInstructions.php new file mode 100644 index 0000000000..984c07d03b --- /dev/null +++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentInstructions.php @@ -0,0 +1,44 @@ +order->get('payment_gateway')->isEmpty()) { + return []; + } + + /** @var \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway */ + $payment_gateway = $this->order->payment_gateway->entity; + /** @var \Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\PaymentGatewayInterface $payment_gateway_plugin */ + $payment_gateway_plugin = $payment_gateway->getPlugin(); + + if ($payment_gateway_plugin instanceof ManualPaymentGatewayInterface && $payment_gateway_plugin->getPaymentInstructions()) { + $pane_form += $payment_gateway_plugin->getPaymentInstructions(); + return $pane_form; + } + + return []; + } + +} diff --git a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php index b9d3bc0098..af9ee4e05d 100644 --- a/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php +++ b/modules/payment/src/Plugin/Commerce/CheckoutPane/PaymentProcess.php @@ -6,6 +6,7 @@ use Drupal\commerce_order\Entity\OrderInterface; use Drupal\commerce_payment\Exception\DeclineException; use Drupal\commerce_payment\Exception\PaymentGatewayException; +use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\ManualPaymentGatewayInterface; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OffsitePaymentGatewayInterface; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\OnsitePaymentGatewayInterface; use Drupal\Core\Form\FormStateInterface; @@ -101,6 +102,7 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state, $payment_gateway_plugin = $payment_gateway->getPlugin(); $payment_storage = $this->entityTypeManager->getStorage('commerce_payment'); + /** @var \Drupal\commerce_payment\Entity\Payment $payment */ $payment = $payment_storage->create([ 'state' => 'new', 'amount' => $this->order->getTotalPrice(), @@ -143,6 +145,24 @@ public function buildPaneForm(array $pane_form, FormStateInterface $form_state, return $pane_form; } + elseif ($payment_gateway_plugin instanceof ManualPaymentGatewayInterface) { + try { + $payment->payment_method = $this->order->payment_method->entity; + $payment_gateway_plugin->createPayment($payment); + $this->checkoutFlow->redirectToStep($next_step_id); + } + catch (DeclineException $e) { + $message = $this->t('We encountered an error processing your payment method. Please verify your details and try again.'); + drupal_set_message($message, 'error'); + $this->redirectToPreviousStep(); + } + catch (PaymentGatewayException $e) { + \Drupal::logger('commerce_payment')->error($e->getMessage()); + $message = $this->t('We encountered an unexpected error processing your payment method. Please try again later.'); + drupal_set_message($message, 'error'); + $this->redirectToPreviousStep(); + } + } else { $this->checkoutFlow->redirectToStep($next_step_id); } diff --git a/modules/payment/src/Plugin/Commerce/PaymentGateway/HasPaymentInstructionsInterface.php b/modules/payment/src/Plugin/Commerce/PaymentGateway/HasPaymentInstructionsInterface.php new file mode 100644 index 0000000000..439aceeca7 --- /dev/null +++ b/modules/payment/src/Plugin/Commerce/PaymentGateway/HasPaymentInstructionsInterface.php @@ -0,0 +1,19 @@ + FALSE, + 'expires' => '', + 'instructions' => [ + 'value' => '', + 'format' => 'plain_text', + ], + ] + parent::defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + $form['manual'] = [ + '#type' => 'fieldset', + '#title' => t('Manual payment settings'), + ]; + $form['manual']['reusable'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Reusable'), + '#description' => $this->t('Check if you want to have reusable payment methods for this gateway.'), + '#default_value' => $this->configuration['reusable'], + ]; + $form['manual']['expires'] = [ + '#type' => 'textfield', + '#title' => $this->t('Expires'), + '#description' => $this->t('An offset from the current time such as "@example1", "@example2 or "@example3". Leave empty for never expires.', ['@example1' => '1 year', '@example2' => '3 months', '@example3' => '60 days']), + '#default_value' => $this->configuration['expires'], + '#size' => 10, + ]; + $form['manual']['instructions'] = [ + '#type' => 'text_format', + '#title' => $this->t('Instructions'), + '#description' => $this->t('Manual payment instructions to be displayed to customer on checkout.'), + '#default_value' => $this->configuration['instructions']['value'], + '#format' => $this->configuration['instructions']['format'], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::validateConfigurationForm($form, $form_state); + + if (!$form_state->getErrors()) { + $form_parents = $form['#parents']; + $form_parents[] = 'manual'; + $values = $form_state->getValue($form_parents); + if (!empty($values['expires'])) { + $convert = strtotime($values['expires']); + if ($convert == -1 || $convert === FALSE) { + $form_state->setError($form['manual']['expires'], $this->t('Invalid offset time format.')); + } + if ($convert < \Drupal::time()->getRequestTime()) { + $form_state->setError($form['manual']['expires'], $this->t('Future offset time is needed for Expires.')); + } + } + } + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + + if (!$form_state->getErrors()) { + $form_parents = $form['#parents']; + $form_parents[] = 'manual'; + $values = $form_state->getValue($form_parents); + $this->configuration['instructions'] = $values['instructions']; + $this->configuration['reusable'] = $values['reusable']; + $this->configuration['expires'] = $values['expires']; + } + } + + /** + * {@inheritdoc} + */ + public function createPayment(PaymentInterface $payment, $capture = TRUE) { + if ($payment->getState()->value != 'new') { + throw new \InvalidArgumentException('The provided payment is in an invalid state.'); + } + $payment_method = $payment->getPaymentMethod(); + if (empty($payment_method)) { + throw new \InvalidArgumentException('The provided payment has no payment method referenced.'); + } + if ($payment_method->isExpired()) { + throw new HardDeclineException('The provided payment method has expired'); + } + + $test = $this->getMode() == 'test'; + $payment->setTest($test); + $payment->state = 'pending'; + $payment->setAuthorizedTime(\Drupal::time()->getRequestTime()); + $payment->save(); + } + + /** + * {@inheritdoc} + */ + public function completePayment(PaymentInterface $payment, Price $amount = NULL) { + if ($payment->getState()->value != 'pending') { + throw new \InvalidArgumentException('Only payments in the "authorization" state can be captured.'); + } + + // If not specified, capture the entire amount. + $amount = $amount ?: $payment->getAmount(); + + $payment->state = 'completed'; + $payment->setAmount($amount); + $payment->setCapturedTime(\Drupal::time()->getRequestTime()); + $payment->save(); + } + + /** + * {@inheritdoc} + */ + public function cancelPayment(PaymentInterface $payment) { + if ($payment->getState()->value != 'pending') { + throw new \InvalidArgumentException('Only payments in the "authorization" state can be voided.'); + } + + $payment->state = 'canceled'; + $payment->save(); + } + + /** + * {@inheritdoc} + */ + public function refundPayment(PaymentInterface $payment, Price $amount = NULL) { + if (!in_array($payment->getState()->value, ['completed', 'partially_refunded'])) { + throw new \InvalidArgumentException('Only payments in the "completed" and "partially_refunded" states can be refunded.'); + } + // If not specified, refund the entire amount. + $amount = $amount ?: $payment->getAmount(); + + // Validate the requested amount. + $balance = $payment->getBalance(); + if ($amount->greaterThan($balance)) { + throw new InvalidRequestException(sprintf("Can't refund more than %s.", $balance->__toString())); + } + + $old_refunded_amount = $payment->getRefundedAmount(); + $new_refunded_amount = $old_refunded_amount->add($amount); + if ($new_refunded_amount->lessThan($payment->getAmount())) { + $payment->state = 'partially_refunded'; + } + else { + $payment->state = 'refunded'; + } + + $payment->setRefundedAmount($new_refunded_amount); + $payment->save(); + } + + /** + * {@inheritdoc} + */ + public function createPaymentMethod(PaymentMethodInterface $payment_method, array $payment_details) { + // No expected keys required for Manual payments. + // Set expires according with configuration. + $expires = $this->configuration['expires'] ? strtotime($this->configuration['expires']) : 0; + // The remote ID returned by the request. + $remote_id = $payment_method->getOwnerId(); + + $payment_method->setRemoteId($remote_id); + $payment_method->setReusable($this->configuration['reusable']); + $payment_method->setExpiresTime($expires); + $payment_method->save(); + } + + /** + * {@inheritdoc} + */ + public function deletePaymentMethod(PaymentMethodInterface $payment_method) { + // Delete the local entity. + $payment_method->delete(); + } + + /** + * {@inheritdoc} + */ + public function getPaymentInstructions() { + $element = NULL; + $instructions = $this->configuration['instructions']; + if (!empty($instructions['value'])) { + $element['instructions'] = [ + '#markup' => check_markup($instructions['value'], $instructions['format']), + ]; + } + + return $element; + } + +} diff --git a/modules/payment/src/Plugin/Commerce/PaymentGateway/ManualPaymentGatewayBase.php b/modules/payment/src/Plugin/Commerce/PaymentGateway/ManualPaymentGatewayBase.php new file mode 100644 index 0000000000..f8a21cab00 --- /dev/null +++ b/modules/payment/src/Plugin/Commerce/PaymentGateway/ManualPaymentGatewayBase.php @@ -0,0 +1,10 @@ +getLabel(); }, $this->paymentMethodTypes); - $form['mode'] = [ - '#type' => 'radios', - '#title' => $this->t('Mode'), - '#options' => $modes, - '#default_value' => $this->configuration['mode'], - '#required' => TRUE, - '#access' => !empty($modes), - ]; + if (count($modes) > 1) { + // Ajax sometimes mixes up with modes. + if (!in_array($this->configuration['mode'], array_keys($modes))) { + $this->configuration = $this->defaultConfiguration(); + } + $form['mode'] = [ + '#type' => 'radios', + '#title' => $this->t('Mode'), + '#options' => $modes, + '#default_value' => $this->configuration['mode'], + '#required' => TRUE, + '#access' => !empty($modes), + ]; + } + else { + $form['mode'] = [ + '#type' => 'value', + '#value' => $this->configuration['mode'], + ]; + } + if (count($payment_method_types) > 1) { $form['payment_method_types'] = [ '#type' => 'checkboxes', @@ -277,7 +295,7 @@ public function validateConfigurationForm(array &$form, FormStateInterface $form * {@inheritdoc} */ public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { - if (!$form_state->getErrors()) { + if (!$form_state->getErrors() && $form_state->isSubmitted()) { $values = $form_state->getValue($form['#parents']); $values['payment_method_types'] = array_filter($values['payment_method_types']); @@ -306,8 +324,23 @@ public function buildPaymentOperations(PaymentInterface $payment) { 'access' => $access, ]; } + if ($this instanceof SupportsManualWorkflowInterface) { + $access = $payment->getState()->value == 'pending'; + $operations['complete'] = [ + 'title' => $this->t('Complete'), + 'page_title' => $this->t('Complete payment'), + 'plugin_form' => 'complete-payment', + 'access' => $access, + ]; + $operations['cancel'] = [ + 'title' => $this->t('Cancel payment'), + 'page_title' => $this->t('Cancel payment'), + 'plugin_form' => 'cancel-payment', + 'access' => $access, + ]; + } if ($this instanceof SupportsRefundsInterface) { - $access = in_array($payment->getState()->value, ['capture_completed', 'capture_partially_refunded']); + $access = in_array($payment->getState()->value, ['capture_completed', 'capture_partially_refunded', 'completed']); $operations['refund'] = [ 'title' => $this->t('Refund'), 'page_title' => $this->t('Refund payment'), diff --git a/modules/payment/src/Plugin/Commerce/PaymentGateway/SupportsManualWorkflowInterface.php b/modules/payment/src/Plugin/Commerce/PaymentGateway/SupportsManualWorkflowInterface.php new file mode 100644 index 0000000000..43553518a7 --- /dev/null +++ b/modules/payment/src/Plugin/Commerce/PaymentGateway/SupportsManualWorkflowInterface.php @@ -0,0 +1,41 @@ +getPaymentGateway(); + // Use billing profile address data to identify the payment method. + if ($billing_profile = $payment_method->getBillingProfile()) { + /** @var \Drupal\address\AddressInterface $address */ + $address = $billing_profile->address->first(); + $name = $address->getGivenName() . ' ' . $address->getFamilyName(); + $location = $address->getAddressLine1() . ', ' . $address->getLocality(); + $args = [ + '@gateway_title' => $payment_gateway->label(), + '@name' => $name, + '@location' => $location, + ]; + $label = $this->t('@gateway_title for @name (@location)', $args); + } + else { + $args = [ + '@gateway_title' => $payment_gateway->label(), + ]; + $label = $this->t('Manual - @gateway_title', $args); + } + + return $label; + } + + /** + * {@inheritdoc} + */ + public function getCreateLabel(PaymentGatewayInterface $payment_gateway) { + return $payment_gateway->label(); + } + + /** + * {@inheritdoc} + */ + public function buildFieldDefinitions() { + // Probably the fields for the Offline payments should be done in the UI. + return []; + } + +} diff --git a/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeBase.php b/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeBase.php index 140864ae2e..7d8576fc55 100644 --- a/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeBase.php +++ b/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeBase.php @@ -2,6 +2,7 @@ namespace Drupal\commerce_payment\Plugin\Commerce\PaymentMethodType; +use Drupal\commerce_payment\Entity\PaymentGatewayInterface; use Drupal\Core\Plugin\PluginBase; /** @@ -19,7 +20,7 @@ public function getLabel() { /** * {@inheritdoc} */ - public function getCreateLabel() { + public function getCreateLabel(PaymentGatewayInterface $payment_gateway) { return $this->pluginDefinition['create_label']; } diff --git a/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeInterface.php b/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeInterface.php index 3ffc5adc98..416ad1df54 100644 --- a/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeInterface.php +++ b/modules/payment/src/Plugin/Commerce/PaymentMethodType/PaymentMethodTypeInterface.php @@ -3,6 +3,7 @@ namespace Drupal\commerce_payment\Plugin\Commerce\PaymentMethodType; use Drupal\commerce\BundlePluginInterface; +use Drupal\commerce_payment\Entity\PaymentGatewayInterface; use Drupal\commerce_payment\Entity\PaymentMethodInterface; /** @@ -21,10 +22,13 @@ public function getLabel(); /** * Gets the payment method type create label. * + * @param \Drupal\commerce_payment\Entity\PaymentGatewayInterface $payment_gateway + * The payment gateway. + * * @return string * The payment method type create label. */ - public function getCreateLabel(); + public function getCreateLabel(PaymentGatewayInterface $payment_gateway); /** * Builds a label for the given payment method. diff --git a/modules/payment/src/Plugin/Commerce/PaymentType/PaymentManual.php b/modules/payment/src/Plugin/Commerce/PaymentType/PaymentManual.php new file mode 100644 index 0000000000..ee4d28caf7 --- /dev/null +++ b/modules/payment/src/Plugin/Commerce/PaymentType/PaymentManual.php @@ -0,0 +1,23 @@ +entity; + + $form['#theme'] = 'confirm_form'; + $form['#attributes']['class'][] = 'confirmation'; + $form['#page_title'] = t('Are you sure you want to cancel the %label payment?', [ + '%label' => $payment->label(), + ]); + $form['#success_message'] = t('Payment canceled.'); + $form['description'] = [ + '#markup' => t('This action cannot be undone.'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = $this->entity; + /** @var \Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsManualWorkflowInterface $payment_gateway_plugin */ + $payment_gateway_plugin = $this->plugin; + $payment_gateway_plugin->cancelPayment($payment); + } + +} diff --git a/modules/payment/src/PluginForm/PaymentCompleteForm.php b/modules/payment/src/PluginForm/PaymentCompleteForm.php new file mode 100644 index 0000000000..efd848b5f3 --- /dev/null +++ b/modules/payment/src/PluginForm/PaymentCompleteForm.php @@ -0,0 +1,23 @@ +getValue($form['#parents']); + $amount = new Price($values['amount']['number'], $values['amount']['currency_code']); + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = $this->entity; + /** @var \Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsManualWorkflowInterface $payment_gateway_plugin */ + $payment_gateway_plugin = $this->plugin; + $payment_gateway_plugin->completePayment($payment, $amount); + } + +} diff --git a/modules/payment/src/PluginForm/PaymentMethodAddForm.php b/modules/payment/src/PluginForm/PaymentMethodAddForm.php index 0a3ece9eba..64dd9b220e 100644 --- a/modules/payment/src/PluginForm/PaymentMethodAddForm.php +++ b/modules/payment/src/PluginForm/PaymentMethodAddForm.php @@ -51,6 +51,9 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta elseif ($payment_method->bundle() == 'paypal') { $form['payment_details'] = $this->buildPayPalForm($form['payment_details'], $form_state); } + elseif ($payment_method->bundle() == 'manual') { + $form['payment_details'] = $this->buildManualForm($form['payment_details'], $form_state); + } /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */ $payment_method = $this->entity; @@ -251,6 +254,30 @@ protected function submitCreditCardForm(array $element, FormStateInterface $form $this->entity->card_exp_year = $values['expiration']['year']; } + /** + * Builds the Manual form. + * + * Empty by default because there is no generic Manual form, it's always + * payment gateway specific. + * + * @param array $element + * The target element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the complete form. + * + * @return array + * The built manual form. + */ + protected function buildManualForm(array $element, FormStateInterface $form_state) { + // Placeholder for the PayPal mail. + $element['manual'] = [ + '#type' => 'hidden', + '#value' => '', + ]; + + return $element; + } + /** * Builds the PayPal form. * diff --git a/modules/payment/templates/commerce-payment-method.html.twig b/modules/payment/templates/commerce-payment-method.html.twig index 43283beba6..d165b49454 100644 --- a/modules/payment/templates/commerce-payment-method.html.twig +++ b/modules/payment/templates/commerce-payment-method.html.twig @@ -24,7 +24,7 @@ {{ payment_method.label }}
- {{ 'Expires'|t }} {{ payment_method_entity.expiresTime|format_date('custom', 'n/Y') }} + {{ 'Expires'|t }} {{ payment_method_expires }}
{{ payment_method.billing_profile }} diff --git a/modules/payment/tests/src/Functional/ManualPaymentAdminTest.php b/modules/payment/tests/src/Functional/ManualPaymentAdminTest.php new file mode 100644 index 0000000000..747d3b9558 --- /dev/null +++ b/modules/payment/tests/src/Functional/ManualPaymentAdminTest.php @@ -0,0 +1,259 @@ +createEntity('profile', [ + 'type' => 'customer', + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + 'uid' => $this->loggedInUser->id(), + ]); + + $this->paymentGateway = $this->createEntity('commerce_payment_gateway', [ + 'id' => 'manual', + 'label' => 'Manual example', + 'plugin' => 'manual', + ]); + $this->paymentGateway->getPlugin()->setConfiguration([ + 'reusable' => '1', + 'expires' => '', + 'instructions' => [ + 'value' => 'Test instructions.', + 'format' => 'plain_text', + ], + ]); + $this->paymentGateway->save(); + $this->paymentMethod = $this->createEntity('commerce_payment_method', [ + 'uid' => $this->loggedInUser->id(), + 'type' => 'manual', + 'payment_gateway' => 'manual', + 'billing_profile' => $profile, + 'expires' => 0, + ]); + + $details = ['manual' => '']; + $this->paymentGateway->getPlugin()->createPaymentMethod($this->paymentMethod, $details); + + $variation = $this->createEntity('commerce_product_variation', [ + 'type' => 'default', + 'sku' => 'test-product-01', + 'price' => new Price('10', 'USD'), + ]); + + $order_item = $this->createEntity('commerce_order_item', [ + 'type' => 'default', + 'quantity' => 1, + 'purchased_entity' => $variation, + 'unit_price' => new Price('10', 'USD'), + ]); + + $this->order = $this->createEntity('commerce_order', [ + 'uid' => $this->loggedInUser->id(), + 'type' => 'default', + 'state' => 'draft', + 'order_items' => [$order_item], + 'store_id' => $this->store, + ]); + + $this->paymentUri = Url::fromRoute('entity.commerce_payment.collection', ['commerce_order' => $this->order->id()])->toString(); + } + + /** + * Tests creating a payment for an order. + */ + public function testPaymentCreation() { + $this->drupalGet($this->paymentUri); + $this->getSession()->getPage()->clickLink('Add payment'); + $this->assertSession()->addressEquals($this->paymentUri . '/add'); + $this->assertSession()->pageTextContains('Manual example for Frederick Pabst (Pabst Blue Ribbon Dr, Milwaukee)'); + + $this->assertSession()->checkboxChecked('payment_method'); + + $this->getSession()->getPage()->pressButton('Continue'); + $this->submitForm(['payment[amount][number]' => '100'], 'Add payment'); + $this->assertSession()->addressEquals($this->paymentUri); + $this->assertSession()->pageTextContains('Pending'); + + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = Payment::load(1); + $this->assertEquals($payment->getOrderId(), $this->order->id()); + $this->assertEquals($payment->getAmount()->getNumber(), '100'); + } + + /** + * Tests completing a payment after creation. + */ + public function testPaymentComplete() { + $payment = $this->createEntity('commerce_payment', [ + 'payment_gateway' => $this->paymentGateway->id(), + 'payment_method' => $this->paymentMethod->id(), + 'order_id' => $this->order->id(), + 'amount' => new Price('10', 'USD'), + ]); + + $this->paymentGateway->getPlugin()->createPayment($payment); + + $this->drupalGet($this->paymentUri); + $this->assertSession()->pageTextContains('Pending'); + + $this->drupalGet($this->paymentUri . '/' . $payment->id() . '/operation/complete'); + $this->submitForm(['payment[amount][number]' => '10'], 'Complete'); + $this->assertSession()->addressEquals($this->paymentUri); + $this->assertSession()->pageTextNotContains('Pending'); + $this->assertSession()->pageTextContains('Completed'); + + $payment = Payment::load($payment->id()); + $this->assertEquals($payment->getState()->getLabel(), 'Completed'); + } + + /** + * Tests refunding a payment after completing. + */ + public function testPaymentRefund() { + $payment = $this->createEntity('commerce_payment', [ + 'payment_gateway' => $this->paymentGateway->id(), + 'payment_method' => $this->paymentMethod->id(), + 'order_id' => $this->order->id(), + 'amount' => new Price('10', 'USD'), + ]); + + $this->paymentGateway->getPlugin()->createPayment($payment); + $this->paymentGateway->getPlugin()->completePayment($payment, new Price('10', 'USD')); + + $this->drupalGet($this->paymentUri); + $this->assertSession()->pageTextContains('Completed'); + + $this->drupalGet($this->paymentUri . '/' . $payment->id() . '/operation/refund'); + $this->submitForm(['payment[amount][number]' => '10'], 'Refund'); + $this->assertSession()->addressEquals($this->paymentUri); + $this->assertSession()->pageTextNotContains('Completed'); + $this->assertSession()->pageTextContains('Refunded'); + + $payment = Payment::load($payment->id()); + $this->assertEquals($payment->getState()->getLabel(), 'Refunded'); + } + + /** + * Tests canceling a payment after creation. + */ + public function testPaymentCancel() { + $payment = $this->createEntity('commerce_payment', [ + 'payment_gateway' => $this->paymentGateway->id(), + 'payment_method' => $this->paymentMethod->id(), + 'order_id' => $this->order->id(), + 'amount' => new Price('10', 'USD'), + ]); + + $this->paymentGateway->getPlugin()->createPayment($payment); + + $this->drupalGet($this->paymentUri); + $this->assertSession()->pageTextContains('Pending'); + + $this->drupalGet($this->paymentUri . '/' . $payment->id() . '/operation/cancel'); + $this->getSession()->getPage()->pressButton('Cancel payment'); + $this->assertSession()->addressEquals($this->paymentUri); + $this->assertSession()->pageTextContains('Canceled'); + + $payment = Payment::load($payment->id()); + $this->assertEquals($payment->getState()->getLabel(), 'Canceled'); + } + + /** + * Tests deleting a payment after creation. + */ + public function testPaymentDelete() { + $payment = $this->createEntity('commerce_payment', [ + 'payment_gateway' => $this->paymentGateway->id(), + 'payment_method' => $this->paymentMethod->id(), + 'order_id' => $this->order->id(), + 'amount' => new Price('10', 'USD'), + ]); + + $this->paymentGateway->getPlugin()->createPayment($payment); + + $this->drupalGet($this->paymentUri); + $this->assertSession()->pageTextContains('Pending'); + + $this->drupalGet($this->paymentUri . '/' . $payment->id() . '/delete'); + $this->getSession()->getPage()->pressButton('Delete'); + $this->assertSession()->addressEquals($this->paymentUri); + $this->assertSession()->pageTextNotContains('Pending'); + + $payment = Payment::load($payment->id()); + $this->assertNull($payment); + } + +} diff --git a/modules/payment/tests/src/Functional/ManualPaymentGatewayTest.php b/modules/payment/tests/src/Functional/ManualPaymentGatewayTest.php new file mode 100644 index 0000000000..88ad48919d --- /dev/null +++ b/modules/payment/tests/src/Functional/ManualPaymentGatewayTest.php @@ -0,0 +1,121 @@ +drupalGet('admin/commerce/config/payment-gateways'); + $this->getSession()->getPage()->clickLink('Add payment gateway'); + $this->assertSession()->addressEquals('admin/commerce/config/payment-gateways/add'); + + $values = [ + 'label' => 'Example', + 'plugin' => 'manual', + 'configuration[mode]' => 'test', + 'configuration[manual][reusable]' => '1', + 'configuration[manual][expires]' => '', + 'configuration[manual][instructions][value]' => 'Test instructions.', + 'status' => '1', + 'id' => 'example', + ]; + $this->submitForm($values, 'Save'); + $this->assertSession()->addressEquals('admin/commerce/config/payment-gateways'); + $this->assertSession()->responseContains('Example'); + $this->assertSession()->responseContains('Test'); + + $payment_gateway = PaymentGateway::load('example'); + $this->assertEquals('example', $payment_gateway->id()); + $this->assertEquals('Example', $payment_gateway->label()); + $this->assertEquals('manual', $payment_gateway->getPluginId()); + $this->assertEquals(TRUE, $payment_gateway->status()); + $payment_gateway_plugin = $payment_gateway->getPlugin(); + $this->assertEquals('test', $payment_gateway_plugin->getMode()); + $configuration = $payment_gateway_plugin->getConfiguration(); + $this->assertEquals('1', $configuration['reusable']); + $this->assertEquals('Test instructions.', $configuration['instructions']['value']); + $this->assertEquals('plain_text', $configuration['instructions']['format']); + $this->assertEmpty($configuration['expires']); + } + + /** + * Tests editing a payment gateway. + */ + public function testPaymentGatewayEditing() { + $values = [ + 'id' => 'edit_example', + 'label' => 'Edit example', + 'plugin' => 'manual', + 'status' => TRUE, + ]; + $payment_gateway = $this->createEntity('commerce_payment_gateway', $values); + + $this->drupalGet('admin/commerce/config/payment-gateways/manage/' . $payment_gateway->id()); + $values += [ + 'configuration[mode]' => 'live', + 'configuration[manual][reusable]' => '1', + 'configuration[manual][expires]' => '', + 'configuration[manual][instructions][value]' => 'Test instructions.', + ]; + $this->submitForm($values, 'Save'); + + \Drupal::entityTypeManager()->getStorage('commerce_payment_gateway')->resetCache(); + $payment_gateway = PaymentGateway::load('edit_example'); + $this->assertEquals('edit_example', $payment_gateway->id()); + $this->assertEquals('Edit example', $payment_gateway->label()); + $this->assertEquals('manual', $payment_gateway->getPluginId()); + $this->assertEquals(TRUE, $payment_gateway->status()); + $payment_gateway_plugin = $payment_gateway->getPlugin(); + $this->assertEquals('live', $payment_gateway_plugin->getMode()); + $configuration = $payment_gateway_plugin->getConfiguration(); + $this->assertEquals('1', $configuration['reusable']); + $this->assertEquals('Test instructions.', $configuration['instructions']['value']); + $this->assertEquals('plain_text', $configuration['instructions']['format']); + $this->assertEmpty($configuration['expires']); + } + + /** + * Tests deleting a payment gateway. + */ + public function testPaymentGatewayDeletion() { + $payment_gateway = $this->createEntity('commerce_payment_gateway', [ + 'id' => 'for_deletion', + 'label' => 'For deletion', + 'plugin' => 'manual', + ]); + $this->drupalGet('admin/commerce/config/payment-gateways/manage/' . $payment_gateway->id() . '/delete'); + $this->submitForm([], 'Delete'); + $this->assertSession()->addressEquals('admin/commerce/config/payment-gateways'); + + $payment_gateway_exists = (bool) PaymentGateway::load('for_deletion'); + $this->assertEmpty($payment_gateway_exists, 'The payment gateway has been deleted from the database.'); + } + +} diff --git a/modules/payment/tests/src/Functional/ManualPaymentMethodTest.php b/modules/payment/tests/src/Functional/ManualPaymentMethodTest.php new file mode 100644 index 0000000000..a84c467dd5 --- /dev/null +++ b/modules/payment/tests/src/Functional/ManualPaymentMethodTest.php @@ -0,0 +1,124 @@ +user = $this->drupalCreateUser($permissions); + $this->drupalLogin($this->user); + + $this->collectionUrl = 'user/' . $this->user->id() . '/payment-methods'; + + /** @var \Drupal\commerce_payment\Entity\PaymentGateway $payment_gateway */ + $this->paymentGateway = $this->createEntity('commerce_payment_gateway', [ + 'id' => 'example_manual', + 'label' => 'Example Manual', + 'plugin' => 'manual', + ]); + $this->paymentGateway->getPlugin()->setConfiguration([ + 'reusable' => '1', + 'expires' => '', + 'instructions' => [ + 'value' => 'Test instructions.', + 'format' => 'plain_text', + ], + 'payment_method_types' => ['manual'], + ]); + $this->paymentGateway->save(); + } + + /** + * Tests creating a payment method. + */ + public function testPaymentMethodCreation() { + /** @var \Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\ManualPaymentGatewayInterface $plugin */ + $this->drupalGet($this->collectionUrl); + $this->getSession()->getPage()->clickLink('Add payment method'); + $this->assertSession()->addressEquals($this->collectionUrl . '/add'); + + $form_values = [ + 'payment_method[billing_information][address][0][address][given_name]' => 'Johnny', + 'payment_method[billing_information][address][0][address][family_name]' => 'Appleseed', + 'payment_method[billing_information][address][0][address][address_line1]' => '123 New York Drive', + 'payment_method[billing_information][address][0][address][locality]' => 'New York City', + 'payment_method[billing_information][address][0][address][administrative_area]' => 'NY', + 'payment_method[billing_information][address][0][address][postal_code]' => '10001', + ]; + $this->submitForm($form_values, 'Save'); + $this->assertSession()->addressEquals($this->collectionUrl); + $this->assertSession()->pageTextContains('Manual for Johnny Appleseed (123 New York Drive, New York City)'); + + $payment_method = PaymentMethod::load(1); + $this->assertEquals($this->user->id(), $payment_method->getOwnerId()); + } + + /** + * Tests deleting a payment method. + */ + public function testPaymentMethodDeletion() { + $payment_method = $this->createEntity('commerce_payment_method', [ + 'uid' => $this->user->id(), + 'type' => 'manual', + 'payment_gateway' => 'example_manual', + ]); + + $details = []; + $this->paymentGateway->getPlugin()->createPaymentMethod($payment_method, $details); + $this->paymentGateway->save(); + + $this->drupalGet($this->collectionUrl . '/' . $payment_method->id() . '/delete'); + + $this->assertSession()->pageTextContains('Manual - Example Manual'); + $this->getSession()->getPage()->pressButton('Delete'); + $this->assertSession()->addressEquals($this->collectionUrl); + + $payment_gateway = PaymentMethod::load($payment_method->id()); + $this->assertNull($payment_gateway); + } + +} diff --git a/modules/payment/tests/src/FunctionalJavascript/ManualPaymentCheckoutTest.php b/modules/payment/tests/src/FunctionalJavascript/ManualPaymentCheckoutTest.php new file mode 100644 index 0000000000..2c6e6e871b --- /dev/null +++ b/modules/payment/tests/src/FunctionalJavascript/ManualPaymentCheckoutTest.php @@ -0,0 +1,261 @@ +createEntity('commerce_product_variation', [ + 'type' => 'default', + 'sku' => strtolower($this->randomMachineName()), + 'price' => [ + 'number' => '39.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' => [$this->store], + ]); + + /** @var \Drupal\commerce_payment\Entity\PaymentGateway $gateway */ + $gateway = PaymentGateway::create([ + 'id' => 'manual', + 'label' => 'Manual', + 'plugin' => 'manual', + ]); + $gateway->getPlugin()->setConfiguration([ + 'reusable' => '1', + 'expires' => '', + 'instructions' => [ + 'value' => 'Test instructions.', + 'format' => 'plain_text', + ], + 'payment_method_types' => ['manual'], + ]); + $gateway->save(); + + $profile = $this->createEntity('profile', [ + 'type' => 'customer', + 'address' => [ + 'country_code' => 'US', + 'postal_code' => '53177', + 'locality' => 'Milwaukee', + 'address_line1' => 'Pabst Blue Ribbon Dr', + 'administrative_area' => 'WI', + 'given_name' => 'Frederick', + 'family_name' => 'Pabst', + ], + 'uid' => $this->adminUser->id(), + ]); + $payment_method = $this->createEntity('commerce_payment_method', [ + 'uid' => $this->adminUser->id(), + 'type' => 'manual', + 'payment_gateway' => 'manual', + 'billing_profile' => $profile, + 'reusable' => TRUE, + 'expires' => strtotime('2028/03/24'), + ]); + $payment_method->setBillingProfile($profile); + $payment_method->save(); + } + + /** + * Tests the structure of the PaymentInformation checkout pane. + */ + public function testPaymentInformation() { + $this->drupalGet($this->product->toUrl()->toString()); + $this->submitForm([], 'Add to cart'); + // The order's payment method must always be available in the pane. + $order = Order::load(1); + $order->payment_method = $this->orderPaymentMethod; + $order->save(); + $this->drupalGet('checkout/1'); + $this->assertSession()->pageTextContains('Payment information'); + + $expected_options = [ + 'Manual for Frederick Pabst (Pabst Blue Ribbon Dr, Milwaukee)', + 'Manual', + ]; + $page = $this->getSession()->getPage(); + foreach ($expected_options as $expected_option) { + $radio_button = $page->findField($expected_option); + $this->assertNotNull($radio_button); + } + $default_radio_button = $page->findField('Manual for Frederick Pabst (Pabst Blue Ribbon Dr, Milwaukee)'); + $this->assertTrue($default_radio_button->getAttribute('checked')); + } + + /** + * Tests checkout with an existing payment method. + */ + public function testCheckoutWithExistingPaymentMethod() { + $this->drupalGet($this->product->toUrl()->toString()); + $this->submitForm([], 'Add to cart'); + $this->drupalGet('checkout/1'); + + $this->submitForm([ + 'payment_information[payment_method]' => '1', + ], 'Continue to review'); + $this->assertSession()->pageTextContains('Payment information'); + $this->assertSession()->pageTextContains('Manual for Frederick Pabst (Pabst Blue Ribbon Dr, Milwaukee)'); + $this->assertSession()->pageTextContains('Expires 3/2028'); + $this->assertSession()->pageTextContains('Frederick Pabst'); + $this->assertSession()->pageTextContains('Pabst Blue Ribbon Dr'); + $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.'); + $this->assertSession()->pageTextContains('Test instructions.'); + + $order = Order::load(1); + $this->assertEquals('manual', $order->get('payment_gateway')->target_id); + $this->assertEquals('1', $order->get('payment_method')->target_id); + + // Verify that a payment was created. + $payment = Payment::load(1); + $this->assertNotNull($payment); + $this->assertEquals($payment->getAmount(), $order->getTotalPrice()); + $this->assertEquals('pending', $payment->getState()->value); + } + + /** + * Tests checkout with a new payment method. + */ + public function testCheckoutWithNewPaymentMethod() { + $this->drupalGet($this->product->toUrl()->toString()); + $this->submitForm([], 'Add to cart'); + $this->drupalGet('checkout/1'); + $radio_button = $this->getSession()->getPage()->findField('Manual'); + $radio_button->click(); + $this->waitForAjaxToFinish(); + + $this->submitForm([ + 'payment_information[add_payment_method][billing_information][address][0][address][given_name]' => 'Johnny', + 'payment_information[add_payment_method][billing_information][address][0][address][family_name]' => 'Appleseed', + 'payment_information[add_payment_method][billing_information][address][0][address][address_line1]' => '123 New York Drive', + 'payment_information[add_payment_method][billing_information][address][0][address][locality]' => 'New York City', + 'payment_information[add_payment_method][billing_information][address][0][address][administrative_area]' => 'NY', + 'payment_information[add_payment_method][billing_information][address][0][address][postal_code]' => '10001', + ], 'Continue to review'); + $this->assertSession()->pageTextContains('Payment information'); + $this->assertSession()->pageTextContains('Manual for Johnny Appleseed (123 New York Drive, New York City)'); + $this->assertSession()->pageTextContains('Expires Never'); + $this->assertSession()->pageTextContains('Johnny Appleseed'); + $this->assertSession()->pageTextContains('123 New York Drive'); + $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.'); + $this->assertSession()->pageTextContains('Test instructions.'); + + $order = Order::load(1); + $this->assertEquals('manual', $order->get('payment_gateway')->target_id); + /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */ + $payment_method = $order->get('payment_method')->entity; + $this->assertEquals('123 New York Drive', $payment_method->getBillingProfile()->get('address')->address_line1); + + // Verify that a payment was created. + $payment = Payment::load(1); + $this->assertNotNull($payment); + $this->assertEquals($payment->getAmount(), $order->getTotalPrice()); + $this->assertEquals('pending', $payment->getState()->value); + } + + /** + * Tests that a declined payment does not complete checkout. + */ + public function testCheckoutWithDeclinedPaymentMethod() { + $this->drupalGet($this->product->toUrl()->toString()); + $this->submitForm([], 'Add to cart'); + $this->drupalGet('checkout/1'); + $radio_button = $this->getSession()->getPage()->findField('Manual'); + $radio_button->click(); + $this->waitForAjaxToFinish(); + + $this->submitForm([ + 'payment_information[add_payment_method][billing_information][address][0][address][given_name]' => 'Johnny', + 'payment_information[add_payment_method][billing_information][address][0][address][family_name]' => 'Appleseed', + 'payment_information[add_payment_method][billing_information][address][0][address][address_line1]' => '123 New York Drive', + 'payment_information[add_payment_method][billing_information][address][0][address][locality]' => 'Somewhere', + 'payment_information[add_payment_method][billing_information][address][0][address][administrative_area]' => 'WI', + 'payment_information[add_payment_method][billing_information][address][0][address][postal_code]' => '53140', + ], 'Continue to review'); + $this->assertSession()->pageTextContains('Payment information'); + $this->assertSession()->pageTextContains('Manual for Johnny Appleseed (123 New York Drive, Somewhere)'); + $this->assertSession()->pageTextContains('Expires Never'); + // Change the expires time so we can get decline for the payment method. + $payment_method = PaymentMethod::load(2); + $payment_method->setExpiresTime(strtotime('-1 day'))->save(); + $this->submitForm([], 'Pay and complete purchase'); + $this->assertSession()->pageTextNotContains('Your order number is 1. You can view your order on your account page when logged in.'); + $this->assertSession()->pageTextContains('We encountered an error processing your payment method. Please verify your details and try again.'); + $this->assertSession()->addressEquals('checkout/1/order_information'); + + // Verify a payment was not created. + $payment = Payment::load(1); + $this->assertNull($payment); + } + +} diff --git a/modules/payment_example/src/Plugin/Commerce/PaymentGateway/Onsite.php b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/Onsite.php index 45fe77bd91..df241fd4e5 100644 --- a/modules/payment_example/src/Plugin/Commerce/PaymentGateway/Onsite.php +++ b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/Onsite.php @@ -92,7 +92,7 @@ public function createPayment(PaymentInterface $payment, $capture = TRUE) { if (empty($payment_method)) { throw new \InvalidArgumentException('The provided payment has no payment method referenced.'); } - if (\Drupal::time()->getRequestTime() >= $payment_method->getExpiresTime()) { + if ($payment_method->isExpired()) { throw new HardDeclineException('The provided payment method has expired'); }