diff --git a/modules/payment/commerce_payment.module b/modules/payment/commerce_payment.module index ba5bc5b3..40ce5242 100644 --- a/modules/payment/commerce_payment.module +++ b/modules/payment/commerce_payment.module @@ -5,6 +5,7 @@ * Provides payment functionality. */ +use Drupal\commerce_order\Entity\OrderInterface; use Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\HasPaymentInstructionsInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; diff --git a/modules/payment/commerce_payment.services.yml b/modules/payment/commerce_payment.services.yml index 8dfba4f7..d26f52ff 100644 --- a/modules/payment/commerce_payment.services.yml +++ b/modules/payment/commerce_payment.services.yml @@ -26,6 +26,12 @@ services: tags: - { name: event_subscriber } + commerce_payment.kernel_subscriber: + class: Drupal\commerce_payment\EventSubscriber\KernelSubscriber + arguments: ['@commerce_payment.order_updater'] + tags: + - { name: event_subscriber } + commerce_payment.order_assign_subscriber: class: Drupal\commerce_payment\EventSubscriber\OrderAssignSubscriber tags: @@ -40,6 +46,12 @@ services: class: Drupal\commerce_payment\PaymentOptionsBuilder arguments: ['@entity_type.manager', '@string_translation'] - commerce_payment.order_manager: - class: Drupal\commerce_payment\PaymentOrderManager + commerce_payment.order_processor: + class: Drupal\commerce_payment\PaymentOrderProcessor + arguments: ['@commerce_payment.order_updater'] + tags: + - { name: commerce_order.order_processor, priority: 400, adjustment_type: tax } + + commerce_payment.order_updater: + class: Drupal\commerce_payment\PaymentOrderUpdater arguments: ['@entity_type.manager'] diff --git a/modules/payment/src/Entity/Payment.php b/modules/payment/src/Entity/Payment.php index 9d26d7d6..f6b38614 100644 --- a/modules/payment/src/Entity/Payment.php +++ b/modules/payment/src/Entity/Payment.php @@ -318,9 +318,10 @@ class Payment extends ContentEntityBase implements PaymentInterface { * {@inheritdoc} */ public function postSave(EntityStorageInterface $storage, $update = TRUE) { - if ($this->isCompleted()) { - $payment_order_manager = \Drupal::service('commerce_payment.order_manager'); - $payment_order_manager->updateTotalPaid($this->getOrder()); + $order = $this->getOrder(); + if ($order && $this->isCompleted()) { + $payment_order_updater = \Drupal::service('commerce_payment.order_updater'); + $payment_order_updater->requestUpdate($order); } } @@ -330,15 +331,11 @@ class Payment extends ContentEntityBase implements PaymentInterface { public static function postDelete(EntityStorageInterface $storage, array $entities) { parent::postDelete($storage, $entities); - // Multiple payments might reference the same order, make sure that each - // order is only updated once. - $orders = []; + $payment_order_updater = \Drupal::service('commerce_payment.order_updater'); foreach ($entities as $entity) { - $orders[$entity->getOrderId()] = $entity->getOrder(); - } - $payment_order_manager = \Drupal::service('commerce_payment.order_manager'); - foreach ($orders as $order) { - $payment_order_manager->updateTotalPaid($order); + if ($order = $entity->getOrder()) { + $payment_order_updater->requestUpdate($order); + } } } diff --git a/modules/payment/src/EventSubscriber/KernelSubscriber.php b/modules/payment/src/EventSubscriber/KernelSubscriber.php new file mode 100644 index 00000000..69dc3141 --- /dev/null +++ b/modules/payment/src/EventSubscriber/KernelSubscriber.php @@ -0,0 +1,48 @@ +paymentOrderUpdater = $payment_order_updater; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + KernelEvents::TERMINATE => ['onTerminate', 400], + ]; + } + + /** + * Updates all remaining orders with pending updates. + * + * @param \Symfony\Component\HttpKernel\Event\PostResponseEvent $event + * The event. + */ + public function onTerminate(PostResponseEvent $event) { + $this->paymentOrderUpdater->updateOrders(); + } + +} diff --git a/modules/payment/src/PaymentOrderManagerInterface.php b/modules/payment/src/PaymentOrderManagerInterface.php deleted file mode 100644 index 4761960b..00000000 --- a/modules/payment/src/PaymentOrderManagerInterface.php +++ /dev/null @@ -1,22 +0,0 @@ -paymentOrderUpdater = $payment_order_updater; + } + + /** + * {@inheritdoc} + */ + public function process(OrderInterface $order) { + if ($this->paymentOrderUpdater->needsUpdate($order)) { + $this->paymentOrderUpdater->updateOrder($order); + } + } + +} diff --git a/modules/payment/src/PaymentOrderManager.php b/modules/payment/src/PaymentOrderUpdater.php similarity index 53% rename from modules/payment/src/PaymentOrderManager.php rename to modules/payment/src/PaymentOrderUpdater.php index 25417664..851d44d9 100644 --- a/modules/payment/src/PaymentOrderManager.php +++ b/modules/payment/src/PaymentOrderUpdater.php @@ -6,7 +6,14 @@ use Drupal\commerce_order\Entity\OrderInterface; use Drupal\commerce_price\Price; use Drupal\Core\Entity\EntityTypeManagerInterface; -class PaymentOrderManager implements PaymentOrderManagerInterface { +class PaymentOrderUpdater implements PaymentOrderUpdaterInterface { + + /** + * The order storage. + * + * @var \Drupal\Core\Entity\EntityStorageInterface + */ + protected $orderStorage; /** * The payment storage. @@ -16,19 +23,54 @@ class PaymentOrderManager implements PaymentOrderManagerInterface { protected $paymentStorage; /** - * Constructs a new PaymentOrderManager object. + * The order IDs that need updating. + * + * @var int[] + */ + protected $updateList = []; + + /** + * Constructs a new PaymentOrderUpdater object. * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. */ public function __construct(EntityTypeManagerInterface $entity_type_manager) { + $this->orderStorage = $entity_type_manager->getStorage('commerce_order'); $this->paymentStorage = $entity_type_manager->getStorage('commerce_payment'); } /** * {@inheritdoc} */ - public function updateTotalPaid(OrderInterface $order) { + public function requestUpdate(OrderInterface $order) { + $this->updateList[$order->id()] = $order->id(); + } + + /** + * {@inheritdoc} + */ + public function needsUpdate(OrderInterface $order) { + return !$order->isNew() && isset($this->updateList[$order->id()]); + } + + /** + * {@inheritdoc} + */ + public function updateOrders() { + if (!empty($this->updateList)) { + /** @var \Drupal\commerce_order\Entity\OrderInterface[] $orders */ + $orders = $this->orderStorage->loadMultiple($this->updateList); + foreach ($orders as $order) { + $this->updateOrder($order, TRUE); + } + } + } + + /** + * {@inheritdoc} + */ + public function updateOrder(OrderInterface $order, $save_order = FALSE) { $previous_total = $order->getTotalPaid(); if (!$previous_total) { // A NULL total indicates an order that doesn't have any items yet. @@ -47,8 +89,12 @@ class PaymentOrderManager implements PaymentOrderManagerInterface { if (!$previous_total->equals($new_total)) { $order->setTotalPaid($new_total); - $order->save(); + if ($save_order) { + $order->save(); + } } + + unset($this->updateList[$order->id()]); } } diff --git a/modules/payment/src/PaymentOrderUpdaterInterface.php b/modules/payment/src/PaymentOrderUpdaterInterface.php new file mode 100644 index 00000000..2a53dcdb --- /dev/null +++ b/modules/payment/src/PaymentOrderUpdaterInterface.php @@ -0,0 +1,64 @@ +setData('test_offsite', ['test' => TRUE]); + $state = \Drupal::state(); + + if ($state->get('offsite_order_data_test_save') === 'before') { + $order->save(); + } + + parent::onReturn($order, $request); + + if ($state->get('offsite_order_data_test_save') === 'after') { + $order->save(); + } + } + +} diff --git a/modules/payment/tests/src/FunctionalJavascript/OffsiteOrderDataTest.php b/modules/payment/tests/src/FunctionalJavascript/OffsiteOrderDataTest.php new file mode 100644 index 00000000..7b75d4bc --- /dev/null +++ b/modules/payment/tests/src/FunctionalJavascript/OffsiteOrderDataTest.php @@ -0,0 +1,128 @@ +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' => 'offsite', + 'label' => 'Off-site', + 'plugin' => 'test_offsite', + 'configuration' => [ + // PayPal uses GET, follow its pattern for this test. + 'redirect_method' => 'get', + 'payment_method_types' => ['credit_card'], + ], + ]); + $gateway->save(); + } + + /** + * Tests the order data saving. + * + * @dataProvider saveDataProvider + */ + public function testSave($when_to_save) { + $state = $this->container->get('state'); + $state->set('offsite_order_data_test_save', $when_to_save); + + $this->drupalGet($this->product->toUrl()->toString()); + $this->submitForm([], 'Add to cart'); + $this->drupalGet('checkout/1'); + + $this->submitForm([ + 'payment_information[billing_information][address][0][address][given_name]' => 'Johnny', + 'payment_information[billing_information][address][0][address][family_name]' => 'Appleseed', + 'payment_information[billing_information][address][0][address][address_line1]' => '123 New York Drive', + 'payment_information[billing_information][address][0][address][locality]' => 'New York City', + 'payment_information[billing_information][address][0][address][administrative_area]' => 'NY', + 'payment_information[billing_information][address][0][address][postal_code]' => '10001', + ], 'Continue to review'); + $this->assertSession()->pageTextContains('Payment information'); + $this->assertSession()->pageTextContains('Example'); + $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.'); + + $order = Order::load(1); + $this->assertEquals('offsite', $order->get('payment_gateway')->target_id); + $this->assertFalse($order->isLocked()); + $this->assertTrue($order->isPaid()); + $this->assertTrue($order->getData('paid_event_dispatched')); + // Confirm that gateway data was set and preserved. + $this->assertEquals($order->getData('test_offsite'), [ + 'test' => TRUE, + ]); + } + + /** + * Data provider for ::testSave. + * + * @return array + * A list of testSave function arguments. + */ + public function saveDataProvider() { + return [ + ['before'], + ['after'], + ]; + } + +} diff --git a/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php b/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php index ec80a57b..8388da80 100644 --- a/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php +++ b/modules/payment/tests/src/FunctionalJavascript/PaymentCheckoutTest.php @@ -83,7 +83,7 @@ class PaymentCheckoutTest extends CommerceWebDriverTestBase { 'stores' => [$this->store], ]); - /** @var \Drupal\commerce_payment\Entity\PaymentGateway $gateway */ + /** @var \Drupal\commerce_payment\Entity\PaymentGateway $skipped_gateway */ $skipped_gateway = PaymentGateway::create([ 'id' => 'onsite_skipped', 'label' => 'On-site Skipped', diff --git a/modules/payment/tests/src/Kernel/Entity/PaymentTest.php b/modules/payment/tests/src/Kernel/Entity/PaymentTest.php index 12296486..e386143b 100644 --- a/modules/payment/tests/src/Kernel/Entity/PaymentTest.php +++ b/modules/payment/tests/src/Kernel/Entity/PaymentTest.php @@ -10,6 +10,7 @@ use Drupal\commerce_payment\Entity\Payment; use Drupal\commerce_payment\Plugin\Commerce\PaymentType\PaymentDefault; use Drupal\commerce_price\Price; use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase; +use Symfony\Component\HttpFoundation\Response; /** * Tests the payment entity. @@ -197,18 +198,23 @@ class PaymentTest extends CommerceKernelTestBase { 'state' => 'completed', ]); $payment->save(); - $this->order = $this->reloadEntity($this->order); + $this->order->save(); $this->assertEquals(new Price('30', 'USD'), $this->order->getTotalPaid()); $this->assertEquals(new Price('0', 'USD'), $this->order->getBalance()); $payment->setRefundedAmount(new Price('15', 'USD')); $payment->setState('partially_refunded'); $payment->save(); - $this->order = $this->reloadEntity($this->order); + $this->order->save(); $this->assertEquals(new Price('15', 'USD'), $this->order->getTotalPaid()); $this->assertEquals(new Price('15', 'USD'), $this->order->getBalance()); $payment->delete(); + // Confirm that if the order isn't explicitly saved, it will be saved + // at the end of the request. + $request = $this->container->get('request_stack')->getCurrentRequest(); + $kernel = $this->container->get('kernel'); + $kernel->terminate($request, new Response()); $this->order = $this->reloadEntity($this->order); $this->assertEquals(new Price('0', 'USD'), $this->order->getTotalPaid()); $this->assertEquals(new Price('30', 'USD'), $this->order->getBalance()); diff --git a/modules/payment/tests/src/Kernel/OrderPaidSubscriberTest.php b/modules/payment/tests/src/Kernel/OrderPaidSubscriberTest.php index b1d320af..d4114656 100644 --- a/modules/payment/tests/src/Kernel/OrderPaidSubscriberTest.php +++ b/modules/payment/tests/src/Kernel/OrderPaidSubscriberTest.php @@ -105,8 +105,8 @@ class OrderPaidSubscriberTest extends CommerceKernelTestBase { 'state' => 'completed', ]); $payment->save(); + $this->order->save(); - $this->order = $this->reloadEntity($this->order); $this->assertEquals('draft', $this->order->getState()->getId()); $this->assertEmpty($this->order->getOrderNumber()); $this->assertEmpty($this->order->getPlacedTime()); @@ -140,8 +140,8 @@ class OrderPaidSubscriberTest extends CommerceKernelTestBase { 'state' => 'completed', ]); $payment->save(); + $this->order->save(); - $this->order = $this->reloadEntity($this->order); $this->assertEquals('completed', $this->order->getState()->getId()); $this->assertFalse($this->order->isLocked()); $this->assertNotEmpty($this->order->getOrderNumber()); diff --git a/modules/payment/tests/src/Kernel/PaymentOrderUpdaterTest.php b/modules/payment/tests/src/Kernel/PaymentOrderUpdaterTest.php new file mode 100644 index 00000000..389f1e94 --- /dev/null +++ b/modules/payment/tests/src/Kernel/PaymentOrderUpdaterTest.php @@ -0,0 +1,205 @@ +installEntitySchema('profile'); + $this->installEntitySchema('commerce_order'); + $this->installEntitySchema('commerce_order_item'); + $this->installEntitySchema('commerce_payment'); + $this->installEntitySchema('commerce_payment_method'); + $this->installConfig('commerce_order'); + $this->installConfig('commerce_payment'); + + $this->paymentOrderUpdater = $this->container->get('commerce_payment.order_updater'); + $this->user = $this->createUser(); + + // An order item type that doesn't need a purchasable entity, for simplicity. + OrderItemType::create([ + 'id' => 'test', + 'label' => 'Test', + 'orderType' => 'default', + ])->save(); + + $payment_gateway = PaymentGateway::create([ + 'id' => 'onsite', + 'label' => 'On-site', + 'plugin' => 'example_onsite', + ]); + $payment_gateway->save(); + + $profile = Profile::create([ + '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->user->id(), + ]); + $profile->save(); + + $payment_method = PaymentMethod::create([ + 'uid' => $this->user->id(), + 'type' => 'credit_card', + 'payment_gateway' => 'onsite', + 'card_type' => 'visa', + 'card_number' => '1111', + 'billing_profile' => $profile, + 'reusable' => TRUE, + 'expires' => strtotime('2028/03/24'), + ]); + $payment_method->setBillingProfile($profile); + $payment_method->save(); + + $first_order_item = OrderItem::create([ + 'type' => 'test', + 'quantity' => 1, + 'unit_price' => new Price('10', 'USD'), + ]); + $first_order_item->save(); + + $first_order = Order::create([ + 'uid' => $this->user, + 'type' => 'default', + 'state' => 'draft', + 'order_items' => [$first_order_item], + 'store_id' => $this->store, + ]); + $first_order->save(); + $this->firstOrder = $this->reloadEntity($first_order); + + $second_order_item = OrderItem::create([ + 'type' => 'test', + 'quantity' => 1, + 'unit_price' => new Price('20', 'USD'), + ]); + $second_order_item->save(); + + $second_order = Order::create([ + 'uid' => $this->user, + 'type' => 'default', + 'state' => 'draft', + 'order_items' => [$second_order_item], + 'store_id' => $this->store, + ]); + $second_order->save(); + $this->secondOrder = $this->reloadEntity($second_order); + } + + /** + * @covers ::requestUpdate + * @covers ::needsUpdate + * @covers ::updateOrders + * @covers ::updateOrder + */ + public function testUpdate() { + $this->assertTrue($this->firstOrder->getTotalPaid()->isZero()); + $this->assertTrue($this->secondOrder->getTotalPaid()->isZero()); + + $this->assertFalse($this->paymentOrderUpdater->needsUpdate($this->firstOrder)); + $this->assertFalse($this->paymentOrderUpdater->needsUpdate($this->secondOrder)); + $this->paymentOrderUpdater->requestUpdate($this->firstOrder); + $this->assertTrue($this->paymentOrderUpdater->needsUpdate($this->firstOrder)); + $this->assertFalse($this->paymentOrderUpdater->needsUpdate($this->secondOrder)); + + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = Payment::create([ + 'type' => 'payment_default', + 'payment_gateway' => 'onsite', + 'order_id' => $this->firstOrder->id(), + 'amount' => new Price('10', 'USD'), + 'state' => 'completed', + ]); + $payment->save(); + + $this->paymentOrderUpdater->updateOrders(); + $this->firstOrder = $this->reloadEntity($this->firstOrder); + $this->secondOrder = $this->reloadEntity($this->secondOrder); + + $this->assertEquals($payment->getAmount(), $this->firstOrder->getTotalPaid()); + $this->assertTrue($this->secondOrder->getTotalPaid()->isZero()); + + // Confirm that the order is not resaved if total_paid hasn't changed. + $changed = $this->firstOrder->getChangedTime(); + sleep(1); + $this->paymentOrderUpdater->requestUpdate($this->firstOrder); + $this->paymentOrderUpdater->updateOrders(); + $this->firstOrder = $this->reloadEntity($this->firstOrder); + $this->assertEquals($changed, $this->firstOrder->getChangedTime()); + } + +} diff --git a/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php index 8cedb543..f699273c 100644 --- a/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php +++ b/modules/payment_example/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php @@ -74,7 +74,7 @@ class OffsiteRedirect extends OffsitePaymentGatewayBase { // @todo Add examples of request validation. $payment_storage = $this->entityTypeManager->getStorage('commerce_payment'); $payment = $payment_storage->create([ - 'state' => 'authorization', + 'state' => 'completed', 'amount' => $order->getBalance(), 'payment_gateway' => $this->entityId, 'order_id' => $order->id(),