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 257d07193a..658f4562e4 100644 --- a/modules/order/src/Entity/Order.php +++ b/modules/order/src/Entity/Order.php @@ -3,6 +3,7 @@ namespace Drupal\commerce_order\Entity; use Drupal\commerce_order\Adjustment; +use Drupal\commerce_price\Price; use Drupal\commerce_store\Entity\StoreInterface; use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\EntityChangedTrait; @@ -385,6 +386,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} */ @@ -643,6 +687,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 d2d146c99f..98db0fbf5b 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 30f5dd5019..d21995e8a4 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); } @@ -94,6 +114,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 @@ -133,6 +158,7 @@ public function testOrder() { $order = Order::create([ 'type' => 'default', 'state' => 'completed', + 'store_id' => $this->store->id(), ]); $order->save(); @@ -178,6 +204,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', @@ -208,6 +235,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()); @@ -221,9 +249,72 @@ public function testOrder() { 'amount' => new Price('5.00', 'USD'), ])); $order->addItem($another_order_item); - $this->assertEquals(new Price('27.00', 'USD'), $order->getTotalPrice()); $collected_adjustments = $order->collectAdjustments(); $this->assertEquals(new Price('10.00', 'USD'), $collected_adjustments[2]->getAmount()); + $this->assertEquals(new Price('27.00', 'USD'), $order->getTotalPrice()); + $this->assertEquals(new Price('27.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('25.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('27.00', 'USD'), $order->getBalance()); + $payment2 = Payment::create([ + 'order_id' => $order->id(), + 'amount' => new Price('27.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('27.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('2.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); diff --git a/modules/payment/src/Entity/Payment.php b/modules/payment/src/Entity/Payment.php index f887f6f957..e1c46d9c1f 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; @@ -25,6 +27,7 @@ * bundle_label = @Translation("Payment type"), * bundle_plugin_type = "commerce_payment_type", * handlers = { + * "event" = "Drupal\commerce_payment\Event\PaymentEvent", * "access" = "Drupal\commerce_payment\PaymentAccessControlHandler", * "list_builder" = "Drupal\commerce_payment\PaymentListBuilder", * "storage" = "Drupal\commerce_payment\PaymentStorage", @@ -290,7 +293,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 +303,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..8da5b4bc9f 100644 --- a/modules/payment/src/Event/PaymentEvents.php +++ b/modules/payment/src/Event/PaymentEvents.php @@ -4,6 +4,80 @@ final class PaymentEvents { + /** + * Name of the event fired after loading an payment. + * + * @Event + * + * @see \Drupal\commerce_payment\Event\PaymentEvent + */ + const PAYMENT_LOAD = 'commerce_payment.commerce_payment.load'; + + /** + * Name of the event fired after creating a new payment. + * + * Fired before the payment is saved. + * + * @Event + * + * @see \Drupal\commerce_payment\Event\PaymentEvent + */ + const PAYMENT_CREATE = 'commerce_payment.commerce_payment.create'; + + /** + * Name of the event fired before saving an payment. + * + * @Event + * + * @see \Drupal\commerce_payment\Event\PaymentEvent + */ + const PAYMENT_PRESAVE = 'commerce_payment.commerce_payment.presave'; + + /** + * Name of the event fired after saving a new payment. + * + * @Event + * + * @see \Drupal\commerce_payment\Event\PaymentEvent + */ + const PAYMENT_INSERT = 'commerce_payment.commerce_payment.insert'; + + /** + * Name of the event fired after saving an existing payment. + * + * @Event + * + * @see \Drupal\commerce_payment\Event\PaymentEvent + */ + const PAYMENT_UPDATE = 'commerce_payment.commerce_payment.update'; + + /** + * Name of the event fired before deleting an payment. + * + * @Event + * + * @see \Drupal\commerce_payment\Event\PaymentEvent + */ + const PAYMENT_PREDELETE = 'commerce_payment.commerce_payment.predelete'; + + /** + * Name of the event fired after deleting an payment. + * + * @Event + * + * @see \Drupal\commerce_payment\Event\PaymentEvent + */ + const PAYMENT_DELETE = 'commerce_payment.commerce_payment.delete'; + + /** + * 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'; + /** * Name of the event fired when payment gateways are loaded for an order. * diff --git a/modules/payment/src/PaymentStorage.php b/modules/payment/src/PaymentStorage.php index f59d567fed..3af30cedf6 100644 --- a/modules/payment/src/PaymentStorage.php +++ b/modules/payment/src/PaymentStorage.php @@ -4,6 +4,9 @@ use Drupal\commerce\CommerceContentEntityStorage; use Drupal\commerce_order\Entity\OrderInterface; +use Drupal\commerce_payment\Entity\PaymentInterface; +use Drupal\commerce_payment\Event\PaymentEvent; +use Drupal\commerce_payment\Event\PaymentEvents; use Drupal\Core\Entity\EntityStorageException; /** @@ -53,7 +56,25 @@ protected function doCreate(array $values) { $values['type'] = $payment_type->getPluginId(); } - return parent::doCreate($values); + $payment = parent::doCreate($values); + + $this->dispatchPaymentEvent($payment, PaymentEvents::PAYMENT_CREATE); + + return $payment; + } + + /** + * 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..916bee9cef --- /dev/null +++ b/modules/payment/tests/modules/payment_events_test/src/EventSubscriber.php @@ -0,0 +1,53 @@ +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_LOAD][] = ['paymentEvent']; + $events[PaymentEvents::PAYMENT_CREATE][] = ['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..7f584004bd --- /dev/null +++ b/modules/payment/tests/src/Kernel/PaymentEventsTest.php @@ -0,0 +1,107 @@ +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() { + // Create a dummy payment. + $payment = Payment::create([ + 'payment_gateway' => 'example', + 'payment_method' => 'credit_card', + 'remote_id' => '123456', + 'amount' => [ + 'number' => '39.99', + 'currency_code' => 'USD', + ], + 'state' => 'capture_completed', + 'test' => TRUE, + ]); + $payment->save(); + + // Check the create event. + $event_recorder = \Drupal::state()->get('payment_events_test.event', FALSE); + $this->assertEquals('commerce_payment.commerce_payment.create', $event_recorder['event_name']); + $this->assertEquals($payment->id(), $event_recorder['event_entity']->id()); + + // Reload the payment. + $this->reloadEntity($payment); + + // Check the load event. + $event_recorder = \Drupal::state()->get('payment_events_test.event', FALSE); + $this->assertEquals('commerce_payment.commerce_payment.load', $event_recorder['event_name']); + } + +}