diff --git a/modules/order/commerce_order.post_update.php b/modules/order/commerce_order.post_update.php index 26dd36ceba..313331df45 100644 --- a/modules/order/commerce_order.post_update.php +++ b/modules/order/commerce_order.post_update.php @@ -5,6 +5,7 @@ * Post update functions for Order. */ +use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Entity\Entity\EntityFormDisplay; /** @@ -158,7 +159,8 @@ function commerce_order_post_update_5() { function commerce_order_post_update_6() { // Remove the default_country setting from any profile form. // That allows Commerce to apply its own default taken from the store. - $query = \Drupal::entityQuery('entity_form_display')->condition('targetEntityType', 'profile'); + $query = \Drupal::entityQuery('entity_form_display') + ->condition('targetEntityType', 'profile'); $ids = $query->execute(); $form_displays = EntityFormDisplay::loadMultiple($ids); foreach ($form_displays as $id => $form_display) { @@ -170,3 +172,18 @@ function commerce_order_post_update_6() { } } } + +/** + * Add 'total_paid' field to 'commerce_order' entities. + */ +function commerce_order_post_update_7() { + $storage_definition = BaseFieldDefinition::create('commerce_price') + ->setLabel(t('Total paid')) + ->setDescription(t('The total amount paid on the order.')) + ->setReadOnly(TRUE) + ->setDisplayConfigurable('form', FALSE) + ->setDisplayConfigurable('view', TRUE); + \Drupal::entityDefinitionUpdateManager() + ->installFieldStorageDefinition('total_paid', 'commerce_order', 'commerce_order', $storage_definition); + return t('The order total paid field was created.'); +} diff --git a/modules/order/src/Entity/Order.php b/modules/order/src/Entity/Order.php index 99eb6bef5d..d95659de00 100644 --- a/modules/order/src/Entity/Order.php +++ b/modules/order/src/Entity/Order.php @@ -4,6 +4,7 @@ use Drupal\commerce\Entity\CommerceContentEntityBase; use Drupal\commerce_order\Adjustment; +use Drupal\commerce_price\Price; use Drupal\commerce_store\Entity\StoreInterface; use Drupal\Core\Entity\EntityChangedTrait; use Drupal\Core\Entity\EntityStorageInterface; @@ -390,6 +391,49 @@ public function getTotalPrice() { } } + /** + * {@inheritdoc} + */ + public function addPayment(Price $amount) { + $this->setTotalPaid($this->getTotalPaid()->add($amount)); + return $this; + } + + /** + * {@inheritdoc} + */ + public function subtractPayment(Price $amount) { + $this->setTotalPaid($this->getTotalPaid()->subtract($amount)); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getTotalPaid() { + if (!$this->get('total_paid')->isEmpty()) { + return $this->get('total_paid')->first()->toPrice(); + } + return new Price('0', $this->getStore()->getDefaultCurrencyCode()); + } + + /** + * {@inheritdoc} + */ + public function setTotalPaid(Price $amount) { + $this->set('total_paid', $amount); + } + + /** + * {@inheritdoc} + */ + public function getBalance() { + if ($this->getTotalPrice() && $this->getTotalPaid()) { + return $this->getTotalPrice()->subtract($this->getTotalPaid()); + } + return $this->getTotalPrice(); + } + /** * {@inheritdoc} */ @@ -671,6 +715,13 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setDisplayConfigurable('form', FALSE) ->setDisplayConfigurable('view', TRUE); + $fields['total_paid'] = BaseFieldDefinition::create('commerce_price') + ->setLabel(t('Total paid')) + ->setDescription(t('The total amount paid on the order.')) + ->setReadOnly(TRUE) + ->setDisplayConfigurable('form', FALSE) + ->setDisplayConfigurable('view', TRUE); + $fields['state'] = BaseFieldDefinition::create('state') ->setLabel(t('State')) ->setDescription(t('The order state.')) diff --git a/modules/order/src/Entity/OrderInterface.php b/modules/order/src/Entity/OrderInterface.php index ba21dc201d..1edd1e57d1 100644 --- a/modules/order/src/Entity/OrderInterface.php +++ b/modules/order/src/Entity/OrderInterface.php @@ -3,6 +3,7 @@ namespace Drupal\commerce_order\Entity; use Drupal\commerce_order\EntityAdjustableInterface; +use Drupal\commerce_price\Price; use Drupal\commerce_store\Entity\StoreInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityChangedInterface; @@ -272,6 +273,50 @@ public function recalculateTotalPrice(); */ public function getTotalPrice(); + /** + * Adds an amount to the order total paid. + * + * @param \Drupal\commerce_price\Price $amount + * The amount to add to the total paid. + * + * @return $this + */ + public function addPayment(Price $amount); + + /** + * Subtracts an amount from the order total paid. + * + * @param \Drupal\commerce_price\Price $amount + * The amount to subtract from the total paid. + * + * @return $this + */ + public function subtractPayment(Price $amount); + + /** + * Gets the total amount paid on the order. + * + * @return \Drupal\commerce_price\Price + * The order total paid amount. + */ + public function getTotalPaid(); + + /** + * Sets the total amount paid on the order. + * + * @param \Drupal\commerce_price\Price $amount + * The amount to set as the order total paid. + */ + public function setTotalPaid(Price $amount); + + /** + * Gets the amount unpaid on the order. + * + * @return \Drupal\commerce_price\Price|null + * The total order amount minus the total paid, or NULL. + */ + public function getBalance(); + /** * Gets the order state. * diff --git a/modules/order/tests/src/Kernel/Entity/OrderTest.php b/modules/order/tests/src/Kernel/Entity/OrderTest.php index a7bff3d94b..420e506dc1 100644 --- a/modules/order/tests/src/Kernel/Entity/OrderTest.php +++ b/modules/order/tests/src/Kernel/Entity/OrderTest.php @@ -8,6 +8,8 @@ use Drupal\commerce_order\Entity\OrderItemType; use Drupal\commerce_price\Exception\CurrencyMismatchException; use Drupal\commerce_price\Price; +use Drupal\commerce_payment\Entity\Payment; +use Drupal\commerce_payment\Entity\PaymentGateway; use Drupal\profile\Entity\Profile; use Drupal\Tests\commerce\Kernel\CommerceKernelTestBase; @@ -27,6 +29,13 @@ class OrderTest extends CommerceKernelTestBase { */ protected $user; + /** + * The payment gateway plugin. + * + * @var \Drupal\commerce_payment\Plugin\Commerce\PaymentGateway\SupportsRefundsInterface + */ + protected $payment_gateway_plugin; + /** * Modules to enable. * @@ -36,6 +45,8 @@ class OrderTest extends CommerceKernelTestBase { 'entity_reference_revisions', 'profile', 'state_machine', + 'commerce_payment', + 'commerce_payment_example', 'commerce_product', 'commerce_order', ]; @@ -49,6 +60,7 @@ protected function setUp() { $this->installEntitySchema('profile'); $this->installEntitySchema('commerce_order'); $this->installEntitySchema('commerce_order_item'); + $this->installEntitySchema('commerce_payment'); $this->installConfig('commerce_order'); // An order item type that doesn't need a purchasable entity, for simplicity. @@ -58,6 +70,14 @@ protected function setUp() { 'orderType' => 'default', ])->save(); + $payment_gateway = PaymentGateway::create([ + 'id' => 'example', + 'label' => 'Example', + 'plugin' => 'example_onsite', + ]); + $payment_gateway->save(); + $this->payment_gateway_plugin = $payment_gateway->getPlugin(); + $user = $this->createUser(); $this->user = $this->reloadEntity($user); } @@ -96,6 +116,11 @@ protected function setUp() { * @covers ::getSubtotalPrice * @covers ::recalculateTotalPrice * @covers ::getTotalPrice + * @covers ::getBalance + * @covers ::addPayment + * @covers ::subtractPayment + * @covers ::setTotalPaid + * @covers ::getTotalPaid * @covers ::getState * @covers ::getRefreshState * @covers ::setRefreshState @@ -138,6 +163,7 @@ public function testOrder() { $order = Order::create([ 'type' => 'default', 'state' => 'completed', + 'store_id' => $this->store->id(), ]); $order->save(); @@ -183,6 +209,7 @@ public function testOrder() { $this->assertNotEmpty($order->hasItem($another_order_item)); $this->assertEquals(new Price('8.00', 'USD'), $order->getTotalPrice()); + $this->assertEquals(new Price('8.00', 'USD'), $order->getBalance()); $adjustments = []; $adjustments[] = new Adjustment([ 'type' => 'custom', @@ -213,6 +240,7 @@ public function testOrder() { $order->removeAdjustment($adjustments[0]); $this->assertEquals(new Price('8.00', 'USD'), $order->getSubtotalPrice()); $this->assertEquals(new Price('18.00', 'USD'), $order->getTotalPrice()); + $this->assertEquals(new Price('18.00', 'USD'), $order->getBalance()); $this->assertEquals([$adjustments[1], $adjustments[2]], $order->getAdjustments()); $order->setAdjustments($adjustments); $this->assertEquals($adjustments, $order->getAdjustments()); @@ -258,6 +286,71 @@ public function testOrder() { unset($multiplied_order_item_adjustments[0]); $this->assertEquals(array_merge($multiplied_order_item_adjustments, $adjustments), $order->collectAdjustments()); + $this->assertEquals(new Price('31.00', 'USD'), $order->getTotalPrice()); + $this->assertEquals(new Price('31.00', 'USD'), $order->getBalance()); + + // Test that payments update the order total paid and balance. + $order->save(); + $payment = Payment::create([ + 'order_id' => $order->id(), + 'amount' => new Price('29.00', 'USD'), + 'payment_gateway' => 'example', + 'state' => 'completed', + ]); + $payment->save(); + $order = Order::load($order->id()); + $this->assertEquals(new Price('2.00', 'USD'), $order->getBalance()); + $this->payment_gateway_plugin->refundPayment($payment, new Price('5.00', 'USD')); + $order = Order::load($order->id()); + $this->assertEquals(new Price('7.00', 'USD'), $order->getBalance()); + $payment->delete(); + $order = Order::load($order->id()); + $this->assertEquals(new Price('31.00', 'USD'), $order->getBalance()); + $payment2 = Payment::create([ + 'order_id' => $order->id(), + 'amount' => new Price('31.00', 'USD'), + 'payment_gateway' => 'example', + 'state' => 'completed', + ]); + $payment2->save(); + $order = Order::load($order->id()); + $this->assertEquals(new Price('0.00', 'USD'), $order->getBalance()); + + // Test that payments can be partially refunded multiple times. + $this->payment_gateway_plugin->refundPayment($payment2, new Price('17.00', 'USD')); + $order = Order::load($order->id()); + $this->assertEquals(new Price('17.00', 'USD'), $order->getBalance()); + $this->payment_gateway_plugin->refundPayment($payment2, new Price('5.00', 'USD')); + $order = Order::load($order->id()); + $this->assertEquals(new Price('22.00', 'USD'), $order->getBalance()); + + // Test that the total paid amount can be set explicitly on the order. + $order->setTotalPaid(new Price('0.00', 'USD')); + $order->save(); + $this->assertEquals(new Price('31.00', 'USD'), $order->getBalance()); + + // Test that payments only substract total when setting to completed. + $order->save(); + $payment = Payment::create([ + 'order_id' => $order->id(), + 'amount' => new Price('25.00', 'USD'), + 'payment_gateway' => 'example', + ]); + $payment->save(); + $order = Order::load($order->id()); + $this->assertEquals(new Price('0.00', 'USD'), $order->getTotalPaid()); + + $payment->setState('completed'); + $payment->save(); + $order = Order::load($order->id()); + $this->assertEquals(new Price('25.00', 'USD'), $order->getTotalPaid()); + $this->assertEquals(new Price('6.00', 'USD'), $order->getBalance()); + + // Test that deleted payments update the order total paid and balance. + $payment->delete(); + $order = Order::load($order->id()); + $this->assertEquals(new Price('0.00', 'USD'), $order->getTotalPaid()); + $this->assertEquals('completed', $order->getState()->value); $order->setRefreshState(Order::REFRESH_ON_SAVE); diff --git a/modules/payment/src/Entity/Payment.php b/modules/payment/src/Entity/Payment.php index f887f6f957..d7a5eab6db 100644 --- a/modules/payment/src/Entity/Payment.php +++ b/modules/payment/src/Entity/Payment.php @@ -2,6 +2,8 @@ namespace Drupal\commerce_payment\Entity; +use Drupal\commerce_payment\Event\PaymentEvents; +use Drupal\commerce_payment\PaymentStorage; use Drupal\commerce_price\Price; use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\EntityMalformedException; @@ -290,7 +292,8 @@ public function preSave(EntityStorageInterface $storage) { $refunded_amount = new Price('0', $this->getAmount()->getCurrencyCode()); $this->setRefundedAmount($refunded_amount); } - // Maintain the authorized completed timestamps. + // Maintain the authorized completed timestamps while also maintaining the + // order balance. $state = $this->getState()->value; $original_state = isset($this->original) ? $this->original->getState()->value : ''; if ($state == 'authorized' && $original_state != 'authorized') { @@ -299,9 +302,33 @@ public function preSave(EntityStorageInterface $storage) { } } if ($state == 'completed' && $original_state != 'completed') { + $this->getOrder()->addPayment($this->getAmount())->save(); if (empty($this->getCompletedTime())) { $this->setCompletedTime(\Drupal::time()->getRequestTime()); } + if ($this->getOrder()->getBalance()->isZero() && $storage instanceof PaymentStorage) { + $storage->dispatchPaymentEvent($this, PaymentEvents::PAYMENT_ORDER_PAID_IN_FULL); + } + } + elseif (in_array($state, ['partially_refunded', 'refunded']) && + in_array($original_state, ['completed', 'partially_refunded'])) { + $original = $this->values['original']; + $net_refund = $this->getRefundedAmount()->subtract($original->getRefundedAmount()); + $this->getOrder()->subtractPayment($net_refund)->save(); + } + } + + /** + * {@inheritdoc} + */ + public static function preDelete(EntityStorageInterface $storage, array $entities) { + parent::preDelete($storage, $entities); + + // Subtract each payment from order. + foreach ($entities as $payment) { + $net_payment = $payment->getAmount() + ->subtract($payment->getRefundedAmount()); + $payment->getOrder()->subtractPayment($net_payment)->save(); } } diff --git a/modules/payment/src/Event/PaymentEvent.php b/modules/payment/src/Event/PaymentEvent.php new file mode 100644 index 0000000000..fec57891ec --- /dev/null +++ b/modules/payment/src/Event/PaymentEvent.php @@ -0,0 +1,42 @@ +payment = $payment; + } + + /** + * Gets the payment. + * + * @return \Drupal\commerce_payment\Entity\PaymentInterface + * Gets the payment. + */ + public function getEntity() { + return $this->payment; + } + +} diff --git a/modules/payment/src/Event/PaymentEvents.php b/modules/payment/src/Event/PaymentEvents.php index 9a4abfeab3..91fb47b0bc 100644 --- a/modules/payment/src/Event/PaymentEvents.php +++ b/modules/payment/src/Event/PaymentEvents.php @@ -13,4 +13,13 @@ final class PaymentEvents { */ const FILTER_PAYMENT_GATEWAYS = 'commerce_payment.filter_payment_gateways'; + /** + * Name of the event fired after paying an order in full. + * + * @Event + * + * @see \Drupal\commerce_payment\Event\PaymentEvent + */ + const PAYMENT_ORDER_PAID_IN_FULL = 'commerce_payment.order_paid_in_full'; + } diff --git a/modules/payment/src/PaymentStorage.php b/modules/payment/src/PaymentStorage.php index f59d567fed..b7663943bf 100644 --- a/modules/payment/src/PaymentStorage.php +++ b/modules/payment/src/PaymentStorage.php @@ -4,6 +4,8 @@ use Drupal\commerce\CommerceContentEntityStorage; use Drupal\commerce_order\Entity\OrderInterface; +use Drupal\commerce_payment\Entity\PaymentInterface; +use Drupal\commerce_payment\Event\PaymentEvent; use Drupal\Core\Entity\EntityStorageException; /** @@ -56,4 +58,18 @@ protected function doCreate(array $values) { return parent::doCreate($values); } + /** + * Notifies other modules about payment events. + * + * @param \Drupal\commerce_payment\Entity\PaymentInterface $payment + * The payment. + * @param string $event_id + * The event identifier defined in + * \Drupal\commerce_payment\Event\PaymentEvents. + */ + public function dispatchPaymentEvent(PaymentInterface $payment, $event_id) { + $event = new PaymentEvent($payment); + $this->eventDispatcher->dispatch($event_id, $event); + } + } diff --git a/modules/payment/tests/modules/payment_events_test/payment_events_test.info.yml b/modules/payment/tests/modules/payment_events_test/payment_events_test.info.yml new file mode 100644 index 0000000000..604ef369cc --- /dev/null +++ b/modules/payment/tests/modules/payment_events_test/payment_events_test.info.yml @@ -0,0 +1,4 @@ +name: 'Configuration events test' +type: module +package: Testing +core: 8.x diff --git a/modules/payment/tests/modules/payment_events_test/payment_events_test.services.yml b/modules/payment/tests/modules/payment_events_test/payment_events_test.services.yml new file mode 100644 index 0000000000..a7df3aa96c --- /dev/null +++ b/modules/payment/tests/modules/payment_events_test/payment_events_test.services.yml @@ -0,0 +1,6 @@ +services: + payment_events_test.event_subscriber: + class: Drupal\payment_events_test\EventSubscriber + arguments: ['@state'] + tags: + - { name: event_subscriber } diff --git a/modules/payment/tests/modules/payment_events_test/src/EventSubscriber.php b/modules/payment/tests/modules/payment_events_test/src/EventSubscriber.php new file mode 100644 index 0000000000..fb49a6d3dd --- /dev/null +++ b/modules/payment/tests/modules/payment_events_test/src/EventSubscriber.php @@ -0,0 +1,52 @@ +state = $state; + } + + /** + * Reacts to payment event. + * + * @param \Drupal\commerce_payment\Event\PaymentEvent $event + * The payment event. + * @param string $name + * The name of the event. + */ + public function paymentEvent(PaymentEvent $event, $name) { + $this->state->set('payment_events_test.event', [ + 'event_name' => $name, + 'event_entity' => $event->getEntity(), + ]); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[PaymentEvents::PAYMENT_ORDER_PAID_IN_FULL][] = ['paymentEvent']; + return $events; + } + +} diff --git a/modules/payment/tests/src/Kernel/PaymentEventsTest.php b/modules/payment/tests/src/Kernel/PaymentEventsTest.php new file mode 100644 index 0000000000..fa131e9a1a --- /dev/null +++ b/modules/payment/tests/src/Kernel/PaymentEventsTest.php @@ -0,0 +1,127 @@ +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'); + + // 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' => 'example', + 'label' => 'Example', + 'plugin' => 'example_onsite', + ]); + $payment_gateway->save(); + + $user = $this->createUser(); + + /** @var \Drupal\commerce_payment\Entity\PaymentMethodInterface $payment_method */ + $payment_method_active = PaymentMethod::create([ + 'type' => 'credit_card', + 'payment_gateway' => 'example', + // Thu, 16 Jan 2020. + 'expires' => '1579132800', + 'uid' => $user->id(), + ]); + $payment_method_active->save(); + } + + /** + * Tests the basic payment events. + */ + public function testPaymentEvents() { + /** @var \Drupal\commerce_order\Entity\OrderItemInterface $order_item */ + $order_item = OrderItem::create([ + 'type' => 'test', + 'quantity' => '1', + 'unit_price' => new Price('39.99', 'USD'), + ]); + $order_item->save(); + $order_item = $this->reloadEntity($order_item); + $order = Order::create([ + 'type' => 'default', + 'state' => 'completed', + 'store_id' => $this->store->id(), + ]); + $order->setItems([$order_item]); + $order->save(); + + $this->assertEquals(new Price('39.99', 'USD'), $order->getTotalPrice()); + $this->assertEquals(new Price('39.99', 'USD'), $order->getBalance()); + + // Create a dummy payment. + $payment = Payment::create([ + 'order_id' => $order->id(), + 'payment_gateway' => 'example', + 'payment_method' => 'credit_card', + 'remote_id' => '123456', + 'amount' => [ + 'number' => '39.99', + 'currency_code' => 'USD', + ], + 'state' => 'completed', + 'test' => TRUE, + ]); + $payment->save(); + + $order = $this->reloadEntity($order); + $this->assertEquals(new Price('0.00', 'USD'), $order->getBalance()); + + // Check the paid in full event. + $event_recorder = \Drupal::state()->get('payment_events_test.event', FALSE); + $this->assertEquals(PaymentEvents::PAYMENT_ORDER_PAID_IN_FULL, $event_recorder['event_name']); + $this->assertEquals($payment->id(), $event_recorder['event_entity']->id()); + } + +}