diff --git a/modules/order/commerce_order.install b/modules/order/commerce_order.install index 8662ffbb73..cdf56467d0 100644 --- a/modules/order/commerce_order.install +++ b/modules/order/commerce_order.install @@ -73,3 +73,17 @@ function commerce_order_update_8204() { $update_manager = \Drupal::entityDefinitionUpdateManager(); $update_manager->installFieldStorageDefinition('uses_legacy_adjustments', 'commerce_order_item', 'commerce_order', $storage_definition); } + +/** + * Add the 'total_paid' field to 'commerce_order' entities. + */ +function commerce_order_update_8205() { + $storage_definition = BaseFieldDefinition::create('commerce_price') + ->setLabel(t('Total paid')) + ->setDescription(t('The total paid price of the order.')) + ->setDisplayConfigurable('form', FALSE) + ->setDisplayConfigurable('view', TRUE); + + $update_manager = \Drupal::entityDefinitionUpdateManager(); + $update_manager->installFieldStorageDefinition('total_paid', 'commerce_order', 'commerce_order', $storage_definition); +} diff --git a/modules/order/src/Entity/Order.php b/modules/order/src/Entity/Order.php index 7427531585..a7f8b3b1d4 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; @@ -402,6 +403,40 @@ public function getTotalPrice() { } } + /** + * {@inheritdoc} + */ + public function getTotalPaid() { + if (!$this->get('total_paid')->isEmpty()) { + return $this->get('total_paid')->first()->toPrice(); + } + elseif ($total_price = $this->getTotalPrice()) { + // Provide a default without storing it, to avoid having to update + // the field if the order currency changes before the order is placed. + return new Price('0', $total_price->getCurrencyCode()); + } + } + + /** + * {@inheritdoc} + */ + public function setTotalPaid(Price $total_paid) { + $this->set('total_paid', $total_paid); + } + + /** + * {@inheritdoc} + */ + public function getBalance() { + if ($total_price = $this->getTotalPrice()) { + $balance = $total_price; + if ($total_paid = $this->getTotalPaid()) { + $balance = $balance->subtract($total_paid); + } + return $balance; + } + } + /** * {@inheritdoc} */ @@ -688,6 +723,12 @@ 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 paid price of the order.')) + ->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 84a9621057..457a8a6c87 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; @@ -275,6 +276,33 @@ public function recalculateTotalPrice(); */ public function getTotalPrice(); + /** + * Gets the total paid price. + * + * @return \Drupal\commerce_price\Price|null + * The total paid price, or NULL. + */ + public function getTotalPaid(); + + /** + * Sets the total paid price. + * + * @param \Drupal\commerce_price\Price $total_paid + * The total paid price. + */ + public function setTotalPaid(Price $total_paid); + + /** + * Gets the order balance. + * + * Calculated by subtracting the total paid price from the total price. + * Can be negative in case the order was overpaid. + * + * @return \Drupal\commerce_price\Price|null + * The order balance, 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 b2d5a41e73..7bd5d718ac 100644 --- a/modules/order/tests/src/Kernel/Entity/OrderTest.php +++ b/modules/order/tests/src/Kernel/Entity/OrderTest.php @@ -97,6 +97,9 @@ protected function setUp() { * @covers ::getSubtotalPrice * @covers ::recalculateTotalPrice * @covers ::getTotalPrice + * @covers ::getTotalPaid + * @covers ::setTotalPaid + * @covers ::getBalance * @covers ::getState * @covers ::getRefreshState * @covers ::setRefreshState @@ -140,6 +143,7 @@ public function testOrder() { $order = Order::create([ 'type' => 'default', 'state' => 'completed', + 'store_id' => $this->store->id(), ]); $order->save(); @@ -227,6 +231,15 @@ public function testOrder() { $order->clearAdjustments(); $this->assertEquals($adjustments, $order->getAdjustments()); + $this->assertEquals(new Price('0', 'USD'), $order->getTotalPaid()); + $this->assertEquals(new Price('17.00', 'USD'), $order->getBalance()); + $order->setTotalPaid(new Price('7.00', 'USD')); + $this->assertEquals(new Price('7.00', 'USD'), $order->getTotalPaid()); + $this->assertEquals(new Price('10.00', 'USD'), $order->getBalance()); + $order->setTotalPaid(new Price('27.00', 'USD')); + $this->assertEquals(new Price('27.00', 'USD'), $order->getTotalPaid()); + $this->assertEquals(new Price('-10.00', 'USD'), $order->getBalance()); + $this->assertEquals('completed', $order->getState()->value); $order->setRefreshState(Order::REFRESH_ON_SAVE); diff --git a/modules/payment/commerce_payment.services.yml b/modules/payment/commerce_payment.services.yml index 3ae13d196b..0c7972d033 100644 --- a/modules/payment/commerce_payment.services.yml +++ b/modules/payment/commerce_payment.services.yml @@ -24,3 +24,7 @@ services: commerce_payment.options_builder: class: Drupal\commerce_payment\PaymentOptionsBuilder arguments: ['@entity_type.manager', '@string_translation'] + + commerce_payment.order_manager: + class: Drupal\commerce_payment\PaymentOrderManager + arguments: ['@entity_type.manager'] diff --git a/modules/payment/src/Entity/Payment.php b/modules/payment/src/Entity/Payment.php index a87dcd1ad9..4774237885 100644 --- a/modules/payment/src/Entity/Payment.php +++ b/modules/payment/src/Entity/Payment.php @@ -311,6 +311,34 @@ public function preSave(EntityStorageInterface $storage) { } } + /** + * {@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()); + } + } + + /** + * {@inheritdoc} + */ + 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 = []; + 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); + } + } + /** * {@inheritdoc} */ diff --git a/modules/payment/src/PaymentOrderManager.php b/modules/payment/src/PaymentOrderManager.php new file mode 100644 index 0000000000..2541766461 --- /dev/null +++ b/modules/payment/src/PaymentOrderManager.php @@ -0,0 +1,54 @@ +paymentStorage = $entity_type_manager->getStorage('commerce_payment'); + } + + /** + * {@inheritdoc} + */ + public function updateTotalPaid(OrderInterface $order) { + $previous_total = $order->getTotalPaid(); + if (!$previous_total) { + // A NULL total indicates an order that doesn't have any items yet. + return; + } + // The new total is always calculated from scratch, to properly handle + // orders that were created before the total_paid field was introduced. + $payments = $this->paymentStorage->loadMultipleByOrder($order); + /** @var \Drupal\commerce_price\Price $new_total */ + $new_total = new Price('0', $previous_total->getCurrencyCode()); + foreach ($payments as $payment) { + if ($payment->isCompleted()) { + $new_total = $new_total->add($payment->getBalance()); + } + } + + if (!$previous_total->equals($new_total)) { + $order->setTotalPaid($new_total); + $order->save(); + } + } + +} diff --git a/modules/payment/src/PaymentOrderManagerInterface.php b/modules/payment/src/PaymentOrderManagerInterface.php new file mode 100644 index 0000000000..4761960bf8 --- /dev/null +++ b/modules/payment/src/PaymentOrderManagerInterface.php @@ -0,0 +1,22 @@ +assertSession()->addressEquals($this->paymentUri); $this->assertSession()->pageTextContains('Completed'); + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([1]); /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ $payment = Payment::load(1); $this->assertEquals($payment->getOrderId(), $this->order->id()); @@ -182,6 +183,7 @@ public function testPaymentCapture() { $this->assertSession()->pageTextNotContains('Authorization'); $this->assertSession()->pageTextContains('Completed'); + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([$payment->id()]); $payment = Payment::load($payment->id()); $this->assertEquals($payment->getState()->getLabel(), 'Completed'); } @@ -208,6 +210,7 @@ public function testPaymentRefund() { $this->assertSession()->pageTextNotContains('Completed'); $this->assertSession()->pageTextContains('Refunded'); + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([$payment->id()]); $payment = Payment::load($payment->id()); $this->assertEquals($payment->getState()->getLabel(), 'Refunded'); } @@ -233,6 +236,7 @@ public function testPaymentVoid() { $this->assertSession()->addressEquals($this->paymentUri); $this->assertSession()->pageTextContains('Authorization (Voided)'); + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([$payment->id()]); $payment = Payment::load($payment->id()); $this->assertEquals($payment->getState()->getLabel(), 'Authorization (Voided)'); } @@ -258,6 +262,7 @@ public function testPaymentDelete() { $this->assertSession()->addressEquals($this->paymentUri); $this->assertSession()->pageTextNotContains('Authorization'); + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([$payment->id()]); $payment = Payment::load($payment->id()); $this->assertNull($payment); } diff --git a/modules/payment/tests/src/Functional/ManualPaymentAdminTest.php b/modules/payment/tests/src/Functional/ManualPaymentAdminTest.php index c1a0a9ab07..d79cc40a52 100644 --- a/modules/payment/tests/src/Functional/ManualPaymentAdminTest.php +++ b/modules/payment/tests/src/Functional/ManualPaymentAdminTest.php @@ -110,6 +110,8 @@ public function testPaymentCreation() { $this->submitForm(['payment[amount][number]' => '100'], 'Add payment'); $this->assertSession()->addressEquals($this->paymentUri); $this->assertSession()->pageTextContains('Pending'); + + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([1]); /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ $payment = Payment::load(1); $this->assertEquals($payment->getOrderId(), $this->order->id()); @@ -122,6 +124,8 @@ public function testPaymentCreation() { $this->submitForm(['payment[amount][number]' => '100', 'payment[received]' => TRUE], 'Add payment'); $this->assertSession()->addressEquals($this->paymentUri); $this->assertSession()->pageTextContains('Completed'); + + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([2]); /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ $payment = Payment::load(2); $this->assertEquals($payment->getOrderId(), $this->order->id()); @@ -147,6 +151,7 @@ public function testPaymentReceive() { $this->assertSession()->pageTextNotContains('Pending'); $this->assertSession()->pageTextContains('Completed'); + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([$payment->id()]); $payment = Payment::load($payment->id()); $this->assertEquals($payment->getState()->getLabel(), 'Completed'); } @@ -168,8 +173,9 @@ public function testPaymentRefund() { $this->assertSession()->pageTextNotContains('Completed'); $this->assertSession()->pageTextContains('Refunded'); + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([$payment->id()]); $payment = Payment::load($payment->id()); - $this->assertEquals($payment->getState()->getLabel(), 'Refunded'); + $this->assertEquals('Refunded', $payment->getState()->getLabel()); } /** @@ -188,6 +194,7 @@ public function testPaymentVoid() { $this->assertSession()->addressEquals($this->paymentUri); $this->assertSession()->pageTextContains('Voided'); + \Drupal::entityTypeManager()->getStorage('commerce_payment')->resetCache([$payment->id()]); $payment = Payment::load($payment->id()); $this->assertEquals($payment->getState()->getLabel(), 'Voided'); } diff --git a/modules/payment/tests/src/Kernel/Entity/PaymentTest.php b/modules/payment/tests/src/Kernel/Entity/PaymentTest.php index e7a2a2b941..978e315754 100644 --- a/modules/payment/tests/src/Kernel/Entity/PaymentTest.php +++ b/modules/payment/tests/src/Kernel/Entity/PaymentTest.php @@ -3,6 +3,8 @@ namespace Drupal\Tests\commerce_payment\Kernel\Entity; use Drupal\commerce_order\Entity\Order; +use Drupal\commerce_order\Entity\OrderItem; +use Drupal\commerce_order\Entity\OrderItemType; use Drupal\commerce_payment\Entity\PaymentGateway; use Drupal\commerce_payment\Entity\Payment; use Drupal\commerce_payment\Plugin\Commerce\PaymentType\PaymentDefault; @@ -69,11 +71,29 @@ protected function setUp() { $user = $this->createUser(); $this->user = $this->reloadEntity($user); + // An order item type that doesn't need a purchasable entity. + OrderItemType::create([ + 'id' => 'test', + 'label' => 'Test', + 'orderType' => 'default', + ])->save(); + + $order_item = OrderItem::create([ + 'title' => 'Membership subscription', + 'type' => 'test', + 'quantity' => 1, + 'unit_price' => [ + 'number' => '30.00', + 'currency_code' => 'USD', + ], + ]); + $order_item->save(); + $order = Order::create([ 'type' => 'default', - 'mail' => $this->user->getEmail(), 'uid' => $this->user->id(), 'store_id' => $this->store->id(), + 'order_items' => [$order_item], ]); $order->save(); $this->order = $this->reloadEntity($order); @@ -158,6 +178,42 @@ public function testPayment() { $this->assertTrue($payment->isCompleted()); } + /** + * Tests the order integration (total_paid field). + * + * @covers ::postSave + * @covers ::postDelete + */ + public function testOrderIntegration() { + $this->assertEquals(new Price('0', 'USD'), $this->order->getTotalPaid()); + $this->assertEquals(new Price('30', 'USD'), $this->order->getBalance()); + + /** @var \Drupal\commerce_payment\Entity\PaymentInterface $payment */ + $payment = Payment::create([ + 'type' => 'payment_default', + 'payment_gateway' => 'example', + 'order_id' => $this->order->id(), + 'amount' => new Price('30', 'USD'), + 'state' => 'completed', + ]); + $payment->save(); + $this->order = $this->reloadEntity($this->order); + $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->assertEquals(new Price('15', 'USD'), $this->order->getTotalPaid()); + $this->assertEquals(new Price('15', 'USD'), $this->order->getBalance()); + + $payment->delete(); + $this->order = $this->reloadEntity($this->order); + $this->assertEquals(new Price('0', 'USD'), $this->order->getTotalPaid()); + $this->assertEquals(new Price('30', 'USD'), $this->order->getBalance()); + } + /** * Tests the timestamp generation on preSave. *