diff --git a/commerce_cart_api.module b/commerce_cart_api.module
index d66d5bb..0032ab4 100644
--- a/commerce_cart_api.module
+++ b/commerce_cart_api.module
@@ -6,6 +6,7 @@
*/
use Drupal\commerce_cart_api\Plugin\Field\FieldType\FormattablePriceItem;
+use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Session\AccountInterface;
@@ -17,3 +18,12 @@ function commerce_cart_api_entity_field_access($operation, FieldDefinitionInterf
$field_access = \Drupal::getContainer()->get('commerce_cart_api.field_access');
return $field_access->handle($operation, $field_definition, $account, $items);
}
+
+/**
+ * Implements hook_entity_base_field_info_alter().
+ */
+function commerce_cart_api_entity_base_field_info_alter(&$fields, EntityTypeInterface $entity_type) {
+ if (isset($fields['coupons']) && ($entity_type->id() === 'commerce_order')) {
+ $fields['coupons']->addConstraint('CouponValid');
+ }
+}
diff --git a/commerce_cart_api.post_update.php b/commerce_cart_api.post_update.php
index 0083ce7..d5c6d42 100644
--- a/commerce_cart_api.post_update.php
+++ b/commerce_cart_api.post_update.php
@@ -11,6 +11,16 @@
function commerce_cart_api_post_update_install_cart_clear_resource() {
$config_updater = \Drupal::getContainer()->get('commerce.config_updater');
$config_updater->import([
- 'rest.resource.commerce_cart_clear'
+ 'rest.resource.commerce_cart_clear',
+ ]);
+}
+
+/**
+ * Ensures the cart coupons resource configuration is present.
+ */
+function commerce_cart_api_post_update_install_cart_coupons_resource() {
+ $config_updater = \Drupal::getContainer()->get('commerce.config_updater');
+ $config_updater->import([
+ 'rest.resource.commerce_cart_coupons',
]);
}
diff --git a/config/optional/rest.resource.commerce_cart_coupons.yml b/config/optional/rest.resource.commerce_cart_coupons.yml
new file mode 100644
index 0000000..af72e74
--- /dev/null
+++ b/config/optional/rest.resource.commerce_cart_coupons.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - commerce_cart_api
+ - commerce_promotion
+ - serialization
+ - user
+id: commerce_cart_coupons
+plugin_id: commerce_cart_coupons
+granularity: resource
+configuration:
+ methods:
+ - GET
+ - PATCH
+ - DELETE
+ formats:
+ - json
+ authentication:
+ - cookie
diff --git a/src/Plugin/Validation/Constraint/CouponValidConstraint.php b/src/Plugin/Validation/Constraint/CouponValidConstraint.php
new file mode 100644
index 0000000..f5cbb26
--- /dev/null
+++ b/src/Plugin/Validation/Constraint/CouponValidConstraint.php
@@ -0,0 +1,26 @@
+getEntity();
+ assert($order instanceof OrderInterface);
+ // Only draft orders should be processed.
+ if ($order->getState()->getId() !== 'draft') {
+ return;
+ }
+ $coupons = $value->referencedEntities();
+ foreach ($coupons as $delta => $coupon) {
+ assert($coupon instanceof CouponInterface);
+ if (!$coupon->available($order) || !$coupon->getPromotion()->applies($order)) {
+ $this->context->buildViolation($constraint->message, ['%code' => $coupon->getCode()])
+ ->atPath((string) $delta)
+ ->addViolation();
+ }
+ }
+ }
+
+}
diff --git a/src/Plugin/rest/resource/CartCouponsResource.php b/src/Plugin/rest/resource/CartCouponsResource.php
new file mode 100644
index 0000000..0c74952
--- /dev/null
+++ b/src/Plugin/rest/resource/CartCouponsResource.php
@@ -0,0 +1,147 @@
+serializer = $serializer;
+ $this->entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+ return new static(
+ $configuration,
+ $plugin_id,
+ $plugin_definition,
+ $container->getParameter('serializer.formats'),
+ $container->get('logger.factory')->get('rest'),
+ $container->get('commerce_cart.cart_provider'),
+ $container->get('commerce_cart.cart_manager'),
+ $container->get('serializer'),
+ $container->get('entity_type.manager')
+ );
+ }
+
+ public function get(OrderInterface $commerce_order) {
+ $response = new ResourceResponse($commerce_order->get('coupons'));
+ $response->addCacheableDependency($commerce_order);
+ return $response;
+ }
+
+ public function patch(OrderInterface $commerce_order, array $unserialized) {
+ // Add coupons.
+ if (!isset($unserialized['coupon_code'])) {
+ throw new BadRequestHttpException('Coupon code not provided.');
+ }
+
+ $coupon_storage = $this->entityTypeManager->getStorage('commerce_promotion_coupon');
+ assert($coupon_storage instanceof CouponStorageInterface);
+
+ $coupon = $coupon_storage->loadEnabledByCode($unserialized['coupon_code']);
+ if (!$coupon instanceof CouponInterface) {
+ throw new UnprocessableEntityHttpException(sprintf('%s is not a valid coupon code.', $unserialized['coupon_code']));
+ }
+
+ $commerce_order->get('coupons')->setValue([$coupon]);
+ $this->validate($commerce_order, ['coupons']);
+ try {
+ $commerce_order->setRefreshState(OrderInterface::REFRESH_ON_SAVE);
+ $commerce_order->save();
+ return new ModifiedResourceResponse($commerce_order, 200);
+ }
+ catch (EntityStorageException $e) {
+ throw new HttpException(500, 'Internal Server Error', $e);
+ }
+ }
+
+ public function delete(OrderInterface $commerce_order) {
+ $commerce_order->get('coupons')->setValue(NULL);
+ $commerce_order->setRefreshState(OrderInterface::REFRESH_ON_SAVE);
+ $commerce_order->save();
+ return new ModifiedResourceResponse(NULL, 204);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getBaseRoute($canonical_path, $method) {
+ $route = parent::getBaseRoute($canonical_path, $method);
+ $parameters = $route->getOption('parameters') ?: [];
+ $parameters['commerce_order']['type'] = 'entity:commerce_order';
+ $route->setOption('parameters', $parameters);
+
+ return $route;
+ }
+
+}
diff --git a/tests/src/Functional/CartCouponsResourceTest.php b/tests/src/Functional/CartCouponsResourceTest.php
new file mode 100644
index 0000000..8849e38
--- /dev/null
+++ b/tests/src/Functional/CartCouponsResourceTest.php
@@ -0,0 +1,95 @@
+setUpAuthorization('GET');
+ $this->setUpAuthorization('PATCH');
+ $this->setUpAuthorization('DELETE');
+ }
+
+ /**
+ * Tests applying a valid coupon.
+ */
+ public function testApplyCoupons() {
+ $promotion = Promotion::create([
+ 'order_types' => ['default'],
+ 'stores' => [$this->store->id()],
+ 'usage_limit' => 1,
+ 'start_date' => '2017-01-01',
+ 'status' => TRUE,
+ 'offer' => [
+ 'target_plugin_id' => 'order_item_percentage_off',
+ 'target_plugin_configuration' => [
+ 'percentage' => '0.5',
+ ],
+ ],
+ ]);
+ $promotion->save();
+
+ $coupon = Coupon::create([
+ 'promotion_id' => $promotion->id(),
+ 'code' => 'coupon_code',
+ 'usage_limit' => 1,
+ 'status' => TRUE,
+ ]);
+ $coupon->save();
+
+ // Add a cart that does belong to the account.
+ $cart = $this->container->get('commerce_cart.cart_provider')->createCart('default', $this->store, $this->account);
+ $this->assertInstanceOf(OrderInterface::class, $cart);
+ // Add order item to the cart.
+ $this->cartManager->addEntity($cart, $this->variation, 2);
+ $this->assertEquals(count($cart->getItems()), 1);
+
+ $url = Url::fromUri('base:cart/1/coupons');
+ $url->setOption('query', ['_format' => static::$format]);
+ $request_options = $this->getAuthenticationRequestOptions('PATCH');
+ $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
+ $request_options[RequestOptions::BODY] = sprintf('{ "coupon_code": "%s"}', $coupon->getCode());
+ $response = $this->request('PATCH', $url, $request_options);
+ $this->assertSame(200, $response->getStatusCode());
+ $response_data = Json::decode($response->getBody()->getContents());
+ $this->assertEquals([$coupon->id()], $response_data['coupons']);
+
+ $order_storage = $this->container->get('entity_type.manager')->getStorage('commerce_order');
+ $order_storage->resetCache();
+ $cart = $order_storage->load($cart->id());
+ $this->assertFalse($cart->get('coupons')->isEmpty());
+ }
+
+ public function testInvalidCoupons() {
+
+ }
+
+}
diff --git a/tests/src/Kernel/CouponAvailableConstraintValidatorTest.php b/tests/src/Kernel/CouponAvailableConstraintValidatorTest.php
new file mode 100644
index 0000000..b255c8e
--- /dev/null
+++ b/tests/src/Kernel/CouponAvailableConstraintValidatorTest.php
@@ -0,0 +1,147 @@
+installEntitySchema('profile');
+ $this->installEntitySchema('commerce_order');
+ $this->installEntitySchema('commerce_order_item');
+ $this->installEntitySchema('commerce_promotion');
+ $this->installEntitySchema('commerce_promotion_coupon');
+ EntityFormMode::create([
+ 'id' => 'commerce_order_item.add_to_cart',
+ 'label' => 'Add to cart',
+ 'targetEntityType' => 'commerce_order_item',
+ ])->save();
+ $this->installSchema('commerce_promotion', ['commerce_promotion_usage']);
+ $this->installConfig([
+ 'profile',
+ 'commerce_order',
+ 'commerce_promotion',
+ ]);
+
+ OrderItemType::create([
+ 'id' => 'test',
+ 'label' => 'Test',
+ 'orderType' => 'default',
+ ])->save();
+ }
+
+ /**
+ * Tests the validator.
+ */
+ public function testValidator() {
+ $order_item = OrderItem::create([
+ 'type' => 'test',
+ 'quantity' => 1,
+ 'unit_price' => new Price('12.00', 'USD'),
+ ]);
+ $order_item->save();
+ /** @var \Drupal\commerce_order\Entity\Order $order */
+ $order = Order::create([
+ 'type' => 'default',
+ 'state' => 'draft',
+ 'mail' => 'test@example.com',
+ 'ip_address' => '127.0.0.1',
+ 'order_number' => '6',
+ 'store_id' => $this->store,
+ 'uid' => $this->createUser(),
+ 'order_items' => [$order_item],
+ ]);
+ $order->setRefreshState(Order::REFRESH_SKIP);
+ $order->save();
+
+ $promotion = Promotion::create([
+ 'order_types' => ['default'],
+ 'stores' => [$this->store->id()],
+ 'usage_limit' => 1,
+ 'start_date' => '2017-01-01',
+ 'status' => TRUE,
+ ]);
+ $promotion->save();
+
+ $coupon = Coupon::create([
+ 'promotion_id' => $promotion->id(),
+ 'code' => 'coupon_code',
+ 'usage_limit' => 1,
+ 'status' => TRUE,
+ ]);
+ $coupon->save();
+
+ $order->get('coupons')->appendItem($coupon);
+ $constraints = $order->validate();
+ $this->assertCount(0, $constraints);
+
+ $coupon->setEnabled(FALSE);
+ // We must save, since ::referencedEntities reloads entities.
+ $coupon->save();
+ $constraints = $order->validate();
+ $this->assertCount(1, $constraints);
+ $this->assertEquals(sprintf('The coupon %s is not available for this order.', $coupon->getCode()), $constraints->get(0)->getMessage());
+ $coupon->setEnabled(TRUE);
+ $coupon->save();
+
+ $constraints = $order->validate();
+ $this->assertCount(0, $constraints);
+
+ $this->container->get('commerce_promotion.usage')->register($order, $promotion, $coupon);
+ $constraints = $order->validate();
+ $this->assertCount(1, $constraints);
+ $this->assertEquals(sprintf('The coupon %s is not available for this order.', $coupon->getCode()), $constraints->get(0)->getMessage());
+
+ $promotion->setUsageLimit(2);
+ $promotion->save();
+ $coupon->setUsageLimit(2);
+ $coupon->save();
+
+ $constraints = $order->validate();
+ $this->assertCount(0, $constraints);
+
+ $order->getState()->applyTransitionById('place');
+ $order->save();
+
+ $coupon->setEnabled(FALSE);
+ $coupon->save();
+
+ // Placed orders should not validate coupons, as price calculation is done.
+ $constraints = $order->validate();
+ $this->assertCount(0, $constraints);
+ }
+
+}