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..de50f0df1d 100644 --- a/modules/payment/src/Entity/Payment.php +++ b/modules/payment/src/Entity/Payment.php @@ -290,7 +290,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,10 +300,31 @@ 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()); } } + 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(); + } } /**