diff --git a/features/promotion/managing_coupons/adding_coupon.feature b/features/promotion/managing_coupons/adding_coupon.feature
index 8acc91cfb0d..5f7c5ece9b3 100644
--- a/features/promotion/managing_coupons/adding_coupon.feature
+++ b/features/promotion/managing_coupons/adding_coupon.feature
@@ -10,7 +10,7 @@ Feature: Adding a new coupon
And it is coupon based promotion
And I am logged in as an administrator
- @ui
+ @ui @api
Scenario: Adding a new coupon
When I want to create a new coupon for this promotion
And I specify its code as "SANTA2016"
@@ -19,4 +19,4 @@ Feature: Adding a new coupon
And I make it valid until "21.04.2017"
And I add it
Then I should be notified that it has been successfully created
- And there should be coupon with code "SANTA2016"
+ And there should be a "Christmas sale" promotion with a coupon code "SANTA2016"
diff --git a/features/promotion/managing_coupons/browsing_coupon.feature b/features/promotion/managing_coupons/browsing_coupons.feature
similarity index 77%
rename from features/promotion/managing_coupons/browsing_coupon.feature
rename to features/promotion/managing_coupons/browsing_coupons.feature
index c94595a8f72..e514a73c6be 100644
--- a/features/promotion/managing_coupons/browsing_coupon.feature
+++ b/features/promotion/managing_coupons/browsing_coupons.feature
@@ -9,8 +9,8 @@ Feature: Browsing promotion coupons
And the store has promotion "Christmas sale" with coupon "SANTA2016"
And I am logged in as an administrator
- @ui
- Scenario: Browsing coupons in store
+ @ui @api
+ Scenario: Browsing coupons of a promotion
When I want to view all coupons of this promotion
And there should be 1 coupon related to this promotion
- And there should be coupon with code "SANTA2016"
+ And there should be a "Christmas sale" promotion with a coupon code "SANTA2016"
diff --git a/features/promotion/managing_coupons/coupon_unique_code_validation.feature b/features/promotion/managing_coupons/coupon_unique_code_validation.feature
index 4f2a1d82bf3..9331d10ee5e 100644
--- a/features/promotion/managing_coupons/coupon_unique_code_validation.feature
+++ b/features/promotion/managing_coupons/coupon_unique_code_validation.feature
@@ -9,7 +9,7 @@ Feature: Coupon unique code validation
And the store has promotion "Christmas sale" with coupon "SANTA2016"
And I am logged in as an administrator
- @ui
+ @api @ui
Scenario: Trying to add coupon with taken code
When I want to create a new coupon for this promotion
And I specify its code as "SANTA2016"
diff --git a/features/promotion/managing_coupons/coupon_validation.feature b/features/promotion/managing_coupons/coupon_validation.feature
index 282d5bd5e1d..d1c42498094 100644
--- a/features/promotion/managing_coupons/coupon_validation.feature
+++ b/features/promotion/managing_coupons/coupon_validation.feature
@@ -10,7 +10,7 @@ Feature: Coupon validation
And it is coupon based promotion
And I am logged in as an administrator
- @ui
+ @ui @api
Scenario: Trying to add a new coupon without specifying its code
When I want to create a new coupon for this promotion
And I do not specify its code
@@ -19,9 +19,9 @@ Feature: Coupon validation
And I make it valid until "26.03.2017"
And I try to add it
Then I should be notified that code is required
- And there should be 0 coupon related to this promotion
+ And there should be 0 coupons related to this promotion
- @ui
+ @ui @api
Scenario: Trying to add a new coupon with usage limit below one
When I want to create a new coupon for this promotion
And I specify its code as "SANTA2016"
@@ -30,9 +30,9 @@ Feature: Coupon validation
And I make it valid until "26.03.2017"
And I try to add it
Then I should be notified that coupon usage limit must be at least one
- And there should be 0 coupon related to this promotion
+ And there should be 0 coupons related to this promotion
- @ui
+ @ui @api
Scenario: Trying to add a new coupon with per customer usage limit below one
When I want to create a new coupon for this promotion
And I specify its code as "SANTA2016"
@@ -41,4 +41,27 @@ Feature: Coupon validation
And I make it valid until "26.03.2017"
And I try to add it
Then I should be notified that coupon usage limit per customer must be at least one
- And there should be 0 coupon related to this promotion
+ And there should be 0 coupons related to this promotion
+
+ @api @no-ui
+ Scenario: Trying to add a new coupon with no promotion
+ When I want to create a new coupon
+ And I specify its code as "RANDOM"
+ And I limit its usage to 30 times
+ And I limit its per customer usage to 3 times
+ And I make it valid until "26.03.2017"
+ And I try to add it
+ Then I should be notified that promotion is required
+ And there should be no coupon with code "RANDOM"
+
+ @api @no-ui
+ Scenario: Trying to add a new coupon for a non-coupon based promotion
+ Given there is a promotion "Flash sale"
+ When I want to create a new coupon for this promotion
+ And I specify its code as "FAST-50"
+ And I limit its usage to 50 times
+ And I limit its per customer usage to 1 time
+ And I make it valid until "26.03.2017"
+ And I try to add it
+ Then I should be notified that only coupon based promotions can have coupons
+ And there should be 0 coupons related to this promotion
diff --git a/features/promotion/managing_coupons/deleting_coupon.feature b/features/promotion/managing_coupons/deleting_coupon.feature
index efe196ac6f3..08a737d4102 100644
--- a/features/promotion/managing_coupons/deleting_coupon.feature
+++ b/features/promotion/managing_coupons/deleting_coupon.feature
@@ -9,7 +9,7 @@ Feature: Deleting a coupon
And the store has promotion "Christmas sale" with coupon "SANTA2016"
And I am logged in as an administrator
- @domain @ui
+ @domain @ui @api
Scenario: Deleted coupon should disappear from the registry
When I delete "SANTA2016" coupon related to this promotion
Then I should be notified that it has been successfully deleted
diff --git a/features/promotion/managing_coupons/editing_coupon.feature b/features/promotion/managing_coupons/editing_coupon.feature
index 0bbe347fdd5..c1e4afe187c 100644
--- a/features/promotion/managing_coupons/editing_coupon.feature
+++ b/features/promotion/managing_coupons/editing_coupon.feature
@@ -9,15 +9,15 @@ Feature: Editing promotion coupon
And the store has promotion "Christmas sale" with coupon "SANTA2016"
And I am logged in as an administrator
- @ui
+ @ui @api
Scenario: Changing coupon expires date
When I want to modify the "SANTA2016" coupon for this promotion
- And I change expires date to "21.05.2019"
+ And I change its expiration date to "21.05.2019"
And I save my changes
Then I should be notified that it has been successfully edited
And this coupon should be valid until "21.05.2019"
- @ui
+ @ui @api
Scenario: Changing coupons usage limit
When I want to modify the "SANTA2016" coupon for this promotion
And I change its usage limit to 50
@@ -25,7 +25,7 @@ Feature: Editing promotion coupon
Then I should be notified that it has been successfully edited
And this coupon should have 50 usage limit
- @ui
+ @ui @api
Scenario: Changing coupons per customer usage limit
When I want to modify the "SANTA2016" coupon for this promotion
And I change its per customer usage limit to 20
@@ -33,7 +33,15 @@ Feature: Editing promotion coupon
Then I should be notified that it has been successfully edited
And this coupon should have 20 per customer usage limit
- @ui
- Scenario: Seeing a disabled code field when editing a coupon
+ @ui @api
+ Scenario: Changing whether it can be reused from cancelled orders
When I want to modify the "SANTA2016" coupon for this promotion
- Then the code field should be disabled
+ And I make it not reusable from cancelled orders
+ And I save my changes
+ Then I should be notified that it has been successfully edited
+ And this coupon should not be reusable from cancelled orders
+
+ @ui @api
+ Scenario: Being unable to change code of promotion coupon
+ When I want to modify the "SANTA2016" coupon for this promotion
+ Then I should not be able to edit its code
diff --git a/features/promotion/managing_coupons/preventing_deletion_of_coupons_in_use.feature b/features/promotion/managing_coupons/preventing_deletion_of_coupons_in_use.feature
index 527ffe64b53..77d37799c1d 100644
--- a/features/promotion/managing_coupons/preventing_deletion_of_coupons_in_use.feature
+++ b/features/promotion/managing_coupons/preventing_deletion_of_coupons_in_use.feature
@@ -15,7 +15,7 @@ Feature: Not being able to delete a coupon which is in use
And the customer chose "Free" shipping method to "United States" with "Cash on Delivery" payment
And I am logged in as an administrator
- @domain @ui
+ @domain @ui @api
Scenario: Being unable to delete a used coupon
When I try to delete "SANTA2016" coupon related to this promotion
Then I should be notified that it is in use and cannot be deleted
diff --git a/features/promotion/managing_coupons/sorting_coupons.feature b/features/promotion/managing_coupons/sorting_coupons.feature
index b3c00f78099..e1eb8ba9d76 100644
--- a/features/promotion/managing_coupons/sorting_coupons.feature
+++ b/features/promotion/managing_coupons/sorting_coupons.feature
@@ -19,69 +19,69 @@ Feature: Sorting listed coupons
And this coupon expires on "20-02-2023"
And I am logged in as an administrator
- @ui
+ @ui @api
Scenario: Coupons are sorted by descending number of uses by default
When I want to view all coupons of this promotion
Then I should see 3 coupons on the list
And the first coupon should have code "Y"
- @ui
+ @ui @api
Scenario: Changing the number of uses sorting order to ascending
Given I am browsing coupons of this promotion
When I sort coupons by ascending number of uses
Then I should see 3 coupons on the list
And the first coupon should have code "X"
- @ui
+ @ui @api
Scenario: Sorting coupons by code in descending order
Given I am browsing coupons of this promotion
When I sort coupons by descending code
Then I should see 3 coupons on the list
And the first coupon should have code "Z"
- @ui
+ @ui @api
Scenario: Sorting coupons by code in ascending order
Given I am browsing coupons of this promotion
When I sort coupons by ascending code
Then I should see 3 coupons on the list
And the first coupon should have code "X"
- @ui @no-postgres
+ @ui @no-postgres @api
Scenario: Sorting coupons by usage limit in descending order
Given I am browsing coupons of this promotion
When I sort coupons by descending usage limit
Then I should see 3 coupons on the list
And the first coupon should have code "X"
- @ui @no-postgres
+ @ui @no-postgres @api
Scenario: Sorting coupons by usage limit in ascending order
Given I am browsing coupons of this promotion
When I sort coupons by ascending usage limit
Then I should see 3 coupons on the list
And the first coupon should have code "Z"
- @ui @no-postgres
+ @ui @no-postgres @api
Scenario: Sorting coupons by usage limit per customer in descending order
Given I am browsing coupons of this promotion
When I sort coupons by descending usage limit per customer
Then I should see 3 coupons on the list
And the first coupon should have code "X"
- @ui @no-postgres
+ @ui @no-postgres @api
Scenario: Sorting coupons by usage limit per customer in ascending order
Given I am browsing coupons of this promotion
When I sort coupons by ascending usage limit per customer
Then I should see 3 coupons on the list
And the first coupon should have code "Y"
- @ui @no-postgres
+ @ui @no-postgres @api
Scenario: Sorting coupons by expiration date in descending order
Given I am browsing coupons of this promotion
When I sort coupons by descending expiration date
Then I should see 3 coupons on the list
And the first coupon should have code "Z"
- @ui @no-postgres
+ @ui @no-postgres @api
Scenario: Sorting coupons by expiration date in ascending order
Given I am browsing coupons of this promotion
When I sort coupons by ascending expiration date
diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php
new file mode 100644
index 00000000000..d13e56e2e7a
--- /dev/null
+++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php
@@ -0,0 +1,466 @@
+client->index(Resources::PROMOTION_COUPONS);
+ $this->client->addFilter(
+ 'promotion',
+ $this->sectionAwareIriConverter->getIriFromResourceInSection($promotion, 'admin'),
+ );
+ $this->client->filter();
+ }
+
+ /**
+ * @When I want to create a new coupon
+ */
+ public function iWantToCreateANewCoupon(): void
+ {
+ $this->client->buildCreateRequest(Resources::PROMOTION_COUPONS);
+ }
+
+ /**
+ * @When /^I want to create a new coupon for (this promotion)$/
+ */
+ public function iWantToCreateANewCouponForPromotion(PromotionInterface $promotion): void
+ {
+ $this->client->buildCreateRequest(Resources::PROMOTION_COUPONS);
+ $this->client->addRequestData(
+ 'promotion',
+ $this->sectionAwareIriConverter->getIriFromResourceInSection($promotion, 'admin'),
+ );
+ }
+
+ /**
+ * @When I want to modify the :coupon coupon for this promotion
+ */
+ public function iWantToModifyTheCouponOfThisPromotion(PromotionCouponInterface $coupon): void
+ {
+ $this->client->buildUpdateRequest(Resources::PROMOTION_COUPONS, $coupon->getCode());
+ }
+
+ /**
+ * @When I (try to) delete :coupon coupon related to this promotion
+ */
+ public function iDeleteCouponRelatedToThisPromotion(PromotionCouponInterface $coupon): void
+ {
+ $this->client->delete(Resources::PROMOTION_COUPONS, $coupon->getCode());
+ }
+
+ /**
+ * @When I specify its code as :code
+ */
+ public function iSpecifyItsCodeAs(string $code): void
+ {
+ $this->client->addRequestData('code', $code);
+ }
+
+ /**
+ * @When I limit its usage to :times time(s)
+ * @When I change its usage limit to :times
+ */
+ public function iLimitItsUsageToTimes(int $times): void
+ {
+ $this->client->addRequestData('usageLimit', $times);
+ }
+
+ /**
+ * @When I limit its per customer usage to :times time(s)
+ * @When I change its per customer usage limit to :times
+ */
+ public function iLimitItsPerCustomerUsageToTimes(int $times): void
+ {
+ $this->client->addRequestData('perCustomerUsageLimit', $times);
+ }
+
+ /**
+ * @When I make it valid until :date
+ * @When I change its expiration date to :date
+ */
+ public function iMakeItValidUntil(\DateTime $date): void
+ {
+ $this->client->addRequestData('expiresAt', $date->format('d-m-Y'));
+ }
+
+ /**
+ * @When I make it not reusable from cancelled orders
+ */
+ public function iMakeItNotReusableFromCancelledOrders(): void
+ {
+ $this->client->addRequestData('reusableFromCancelledOrders', false);
+ }
+
+ /**
+ * @When I do not specify its :field
+ */
+ public function iDoNotSpecifyIts(): void
+ {
+ // Intentionally left blank
+ }
+
+ /**
+ * @When I (try to) add it
+ */
+ public function iAddIt(): void
+ {
+ $this->client->create();
+ }
+
+ /**
+ * @When /^I sort coupons by (ascending|descending) number of uses$/
+ */
+ public function iSortCouponsByNumberOfUses(string $order): void
+ {
+ $this->sortBy($order, 'used');
+ }
+
+ /**
+ * @When /^I sort coupons by (ascending|descending) code$/
+ */
+ public function iSortCouponsByCode(string $order): void
+ {
+ $this->sortBy($order, 'code');
+ }
+
+ /**
+ * @When /^I sort coupons by (ascending|descending) usage limit$/
+ */
+ public function iSortCouponsByUsageLimit(string $order): void
+ {
+ $this->sortBy($order, 'usageLimit');
+ }
+
+ /**
+ * @When /^I sort coupons by (ascending|descending) usage limit per customer$/
+ */
+ public function iSortCouponsByPerCustomerUsageLimit(string $order): void
+ {
+ $this->sortBy($order, 'perCustomerUsageLimit');
+ }
+
+ /**
+ * @When /^I sort coupons by (ascending|descending) expiration date$/
+ */
+ public function iSortCouponsByExpirationDate(string $order): void
+ {
+ $this->sortBy($order, 'expiresAt');
+ }
+
+ /**
+ * @Then /^there should(?:| still) be (\d+) coupons? related to (this promotion)$/
+ */
+ public function thereShouldBeCountCouponsRelatedToThisPromotion(int $count, PromotionInterface $promotion): void
+ {
+ $coupons = $this->responseChecker->getCollection(
+ $this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()),
+ );
+ Assert::same(count($coupons), $count);
+ }
+
+ /**
+ * @Then there should be a :promotion promotion with a coupon code :code
+ */
+ public function thereShouldBeACouponWithCode(PromotionInterface $promotion, string $code): void
+ {
+ Assert::true($this->responseChecker->hasItemWithValue(
+ $this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()),
+ 'code',
+ $code,
+ ));
+ }
+
+ /**
+ * @Then there should be no coupon with code :code
+ */
+ public function thereShouldBeNoCouponWithCode(string $code): void
+ {
+ Assert::false($this->responseChecker->hasItemWithValue(
+ $this->client->index(Resources::PROMOTION_COUPONS),
+ 'code',
+ $code,
+ ));
+ }
+
+ /**
+ * @Then I should see :count coupons on the list
+ */
+ public function iShouldSeeCountCouponsOnTheList(int $count): void
+ {
+ Assert::same($this->responseChecker->countCollectionItems($this->client->getLastResponse()), $count);
+ }
+
+ /**
+ * @Then the first coupon should have code :code
+ */
+ public function theFirstCouponShouldHaveCode(string $code): void
+ {
+ Assert::true($this->responseChecker->hasItemOnPositionWithValue(
+ $this->client->getLastResponse(),
+ 0,
+ 'code',
+ $code,
+ ));
+ }
+
+ /**
+ * @Then I should be notified that it has been successfully created
+ */
+ public function iShouldBeNotifiedThatItHasBeenSuccessfullyCreated(): void
+ {
+ Assert::true(
+ $this->responseChecker->isCreationSuccessful($this->client->getLastResponse()),
+ 'Promotion coupon could not be created',
+ );
+ }
+
+ /**
+ * @Then this coupon should be valid until :date
+ */
+ public function thisCouponShouldBeValidUntil(\DateTime $date): void
+ {
+ $actualDate = \DateTime::createFromFormat(
+ 'Y-m-d h:i:s',
+ $this->responseChecker->getValue($this->client->getLastResponse(), 'expiresAt'),
+ );
+
+ Assert::same(
+ $actualDate->format('Y-m-d'),
+ $date->format('Y-m-d'),
+ );
+ }
+
+ /**
+ * @Then this coupon should have :limit usage limit
+ */
+ public function thisCouponShouldHaveUsageLimit(int $limit): void
+ {
+ Assert::same(
+ $this->responseChecker->getValue($this->client->getLastResponse(), 'usageLimit'),
+ $limit,
+ );
+ }
+
+ /**
+ * @Then this coupon should have :limit per customer usage limit
+ */
+ public function thisCouponShouldHavePerCustomerUsageLimit(int $limit): void
+ {
+ Assert::same(
+ $this->responseChecker->getValue($this->client->getLastResponse(), 'perCustomerUsageLimit'),
+ $limit,
+ );
+ }
+
+ /**
+ * @Then this coupon should not be reusable from cancelled orders
+ */
+ public function thisCouponShouldNotBeReusableFromCancelledOrders(): void
+ {
+ Assert::false($this->responseChecker->getValue(
+ $this->client->getLastResponse(),
+ 'reusableFromCancelledOrders',
+ ));
+ }
+
+ /**
+ * @Then I should be notified that it has been successfully deleted
+ */
+ public function iShouldBeNotifiedThatItHasBeenSuccessfullyDeleted(): void
+ {
+ Assert::true(
+ $this->responseChecker->isDeletionSuccessful($this->client->getLastResponse()),
+ 'Promotion coupon could not be deleted',
+ );
+ }
+
+ /**
+ * @Then /^(this coupon) should no longer exist in the coupon registry$/
+ */
+ public function couponShouldNotExistInTheRegistry(PromotionCouponInterface $coupon): void
+ {
+ Assert::false($this->responseChecker->hasItemWithValue(
+ $this->client->index(Resources::PROMOTION_COUPONS),
+ 'code',
+ $coupon->getCode(),
+ ));
+ }
+
+ /**
+ * @Then /^(this coupon) should still exist in the registry$/
+ */
+ public function couponShouldStillExistInTheRegistry(PromotionCouponInterface $coupon): void
+ {
+ Assert::true($this->responseChecker->hasItemWithValue(
+ $this->client->index(Resources::PROMOTION_COUPONS),
+ 'code',
+ $coupon->getCode(),
+ ));
+ }
+
+ /**
+ * @Then /^there should still be only one coupon with code "([^"]+)" related to (this promotion)$/
+ */
+ public function thereShouldStillBeOnlyOneCouponWithCodeRelatedTo(string $code, PromotionInterface $promotion): void
+ {
+ $coupons = $this->responseChecker->getCollectionItemsWithValue(
+ $this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()),
+ 'code',
+ $code
+ );
+
+ Assert::count($coupons, 1);
+ }
+
+ /**
+ * @Then I should be notified that it is in use and cannot be deleted
+ */
+ public function iShouldBeNotifiedThatItIsInUseAndCannotBeDeleted(): void
+ {
+ Assert::contains(
+ $this->responseChecker->getError($this->client->getLastResponse()),
+ 'Cannot delete, the promotion coupon is in use.',
+ );
+ }
+
+ /**
+ * @Then I should be notified that code is required
+ */
+ public function iShouldBeNotifiedThatCodeIsRequired(): void
+ {
+ $response = $this->client->getLastResponse();
+ Assert::false(
+ $this->responseChecker->isCreationSuccessful($response),
+ 'Coupon has been created successfully, but it should not',
+ );
+ Assert::same($this->responseChecker->getError($response), 'code: Please enter coupon code.');
+ }
+
+ /**
+ * @Then I should be notified that coupon usage limit must be at least one
+ */
+ public function iShouldBeNotifiedThatCouponUsageLimitMustBeAtLeastOne(): void
+ {
+ $response = $this->client->getLastResponse();
+ Assert::false(
+ $this->responseChecker->isCreationSuccessful($response),
+ 'Coupon has been created successfully, but it should not',
+ );
+ Assert::same(
+ $this->responseChecker->getError($response),
+ 'usageLimit: Coupon usage limit must be at least 1.',
+ );
+ }
+
+ /**
+ * @Then I should be notified that coupon usage limit per customer must be at least one
+ */
+ public function iShouldBeNotifiedThatCouponUsageLimitPerCustomerMustBeAtLeastOne(): void
+ {
+ $response = $this->client->getLastResponse();
+ Assert::false(
+ $this->responseChecker->isCreationSuccessful($response),
+ 'Coupon has been created successfully, but it should not',
+ );
+ Assert::same(
+ $this->responseChecker->getError($response),
+ 'perCustomerUsageLimit: Coupon usage limit per customer must be at least 1.',
+ );
+ }
+
+ /**
+ * @Then I should be notified that promotion is required
+ */
+ public function iShouldBeNotifiedThatPromotionIsRequired(): void
+ {
+ $response = $this->client->getLastResponse();
+ Assert::false(
+ $this->responseChecker->isCreationSuccessful($response),
+ 'Coupon has been created successfully, but it should not',
+ );
+ Assert::same(
+ $this->responseChecker->getError($response),
+ 'promotion: Please provide a promotion for this coupon.',
+ );
+ }
+
+ /**
+ * @Then I should be notified that only coupon based promotions can have coupons
+ */
+ public function iShouldBeNotifiedThatOnlyCouponBasedPromotionsCanHaveCoupons(): void
+ {
+ $response = $this->client->getLastResponse();
+ Assert::false(
+ $this->responseChecker->isCreationSuccessful($response),
+ 'Coupon has been created successfully, but it should not',
+ );
+ Assert::same(
+ $this->responseChecker->getError($response),
+ 'promotion: Only coupon based promotions can have coupons.',
+ );
+ }
+
+ /**
+ * @Then I should be notified that coupon with this code already exists
+ */
+ public function iShouldBeNotifiedThatCouponWithThisCodeAlreadyExists(): void
+ {
+ $response = $this->client->getLastResponse();
+ Assert::false(
+ $this->responseChecker->isCreationSuccessful($response),
+ 'Coupon has been created successfully, but it should not',
+ );
+ Assert::same(
+ $this->responseChecker->getError($response),
+ 'code: This coupon already exists.',
+ );
+ }
+
+ /**
+ * @Then I should not be able to edit its code
+ */
+ public function iShouldNotBeAbleToEditItsCode(): void
+ {
+ $this->client->updateRequestData(['code' => 'NEW_CODE']);
+
+ Assert::false($this->responseChecker->hasValue($this->client->update(), 'code', 'NEW_CODE'));
+ }
+
+ private function sortBy(string $order, string $field): void
+ {
+ $this->client->sort([$field => str_starts_with($order, 'de') ? 'desc' : 'asc']);
+ }
+}
diff --git a/src/Sylius/Behat/Context/Api/Resources.php b/src/Sylius/Behat/Context/Api/Resources.php
index edd14cef201..b6874650827 100644
--- a/src/Sylius/Behat/Context/Api/Resources.php
+++ b/src/Sylius/Behat/Context/Api/Resources.php
@@ -71,6 +71,8 @@ final class Resources
public const PROMOTIONS = 'promotions';
+ public const PROMOTION_COUPONS = 'promotion-coupons';
+
public const PROVINCES = 'provinces';
public const SHIPMENTS = 'shipments';
diff --git a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php
index d4373f32085..c3977d7783e 100644
--- a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php
+++ b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php
@@ -98,7 +98,7 @@ public function specifySuffixAs(string $suffix): void
}
/**
- * @When /^I limit generated coupons usage to (\d+) times$/
+ * @When /^I limit generated coupons usage to (\d+) times?$/
*/
public function iSetGeneratedCouponsUsageLimitTo(int $limit)
{
@@ -123,7 +123,7 @@ public function iSpecifyItsCodeAs($code = null)
}
/**
- * @When I limit its usage to :limit times
+ * @When I limit its usage to :limit time(s)
*/
public function iLimitItsUsageLimitTo(int $limit)
{
@@ -149,7 +149,7 @@ public function iSpecifyItsAmountAs(?int $amount = null): void
}
/**
- * @When /^I limit its per customer usage to ([^"]+) times$/
+ * @When /^I limit its per customer usage to ([^"]+) times?$/
*/
public function iLimitItsPerCustomerUsageLimitTo(int $limit)
{
@@ -164,6 +164,14 @@ public function iChangeItsPerCustomerUsageLimitTo(int $limit)
$this->updatePage->setCustomerUsageLimit($limit);
}
+ /**
+ * @When I make it not reusable from cancelled orders
+ */
+ public function iMakeItReusableFromCancelledOrders(): void
+ {
+ $this->updatePage->toggleReusableFromCancelledOrders(false);
+ }
+
/**
* @When I make it valid until :date
*/
@@ -173,9 +181,9 @@ public function iMakeItValidUntil(\DateTimeInterface $date)
}
/**
- * @When I change expires date to :date
+ * @When I change its expiration date to :date
*/
- public function iChangeExpiresDateTo(\DateTimeInterface $date)
+ public function iChangeItsExpirationDateTo(\DateTimeInterface $date)
{
$this->updatePage->setExpiresAt($date);
}
@@ -275,9 +283,7 @@ public function iSortCouponsByExpirationDate(string $order): void
}
/**
- * @Then /^there should be (0|1) coupon related to (this promotion)$/
- * @Then /^there should be (\b(?![01]\b)\d{1,9}\b) coupons related to (this promotion)$/
- * @Then /^there should still be (\d+) coupons related to (this promotion)$/
+ * @Then /^there should(?:| still) be (\d+) coupons? related to (this promotion)$/
*/
public function thereShouldBeCouponRelatedTo(int $number, PromotionInterface $promotion): void
{
@@ -315,9 +321,10 @@ public function iShouldSeeASingleCouponInTheList(): void
}
/**
- * @Then /^there should be coupon with code "([^"]+)"$/
+ * @Then there should be a coupon with code :code
+ * @Then there should be a :promotion promotion with a coupon code :code
*/
- public function thereShouldBeCouponWithCode($code)
+ public function thereShouldBeCouponWithCode(string $code): void
{
Assert::true($this->indexPage->isSingleResourceOnPage(['code' => $code]));
}
@@ -325,9 +332,9 @@ public function thereShouldBeCouponWithCode($code)
/**
* @Then this coupon should be valid until :date
*/
- public function thisCouponShouldBeValidUntil(\DateTimeInterface $date)
+ public function thisCouponShouldBeValidUntil(\DateTime $date)
{
- Assert::true($this->indexPage->isSingleResourceOnPage(['expiresAt' => date('d-m-Y', $date->getTimestamp())]));
+ Assert::true($this->indexPage->isSingleResourceOnPage(['expiresAt' => $date->format('d-m-Y')]));
}
/**
@@ -355,9 +362,20 @@ public function thisCouponShouldHavePerCustomerUsageLimit($limit)
}
/**
+ * @Then /^(this coupon) should not be reusable from cancelled orders$/
+ */
+ public function thisCouponShouldBeReusableFromCancelledOrders(PromotionCouponInterface $coupon): void
+ {
+ $this->updatePage->open(['id' => $coupon->getId(), 'promotionId' => $coupon->getPromotion()->getId()]);
+
+ Assert::false($this->updatePage->isReusableFromCancelledOrders());
+ }
+
+ /**
+ * @Then I should not be able to edit its code
* @Then the code field should be disabled
*/
- public function theCodeFieldShouldBeDisabled()
+ public function iShouldNotBeAbleToEditItsCode(): void
{
Assert::true($this->updatePage->isCodeDisabled());
}
diff --git a/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePage.php b/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePage.php
index 73ebd9ae60d..3cc55455401 100644
--- a/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePage.php
+++ b/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePage.php
@@ -16,6 +16,7 @@
use Behat\Mink\Element\NodeElement;
use Sylius\Behat\Behaviour\ChecksCodeImmutability;
use Sylius\Behat\Page\Admin\Crud\UpdatePage as BaseUpdatePage;
+use Webmozart\Assert\Assert;
class UpdatePage extends BaseUpdatePage implements UpdatePageInterface
{
@@ -38,6 +39,20 @@ public function setUsageLimit(string $limit): void
$this->getDocument()->fillField('Usage limit', $limit);
}
+ public function isReusableFromCancelledOrders(): bool
+ {
+ return $this->getElement('reusable_from_cancelled_orders')->isChecked();
+ }
+
+ public function toggleReusableFromCancelledOrders(bool $reusable): void
+ {
+ $toggle = $this->getElement('reusable_from_cancelled_orders');
+
+ Assert::notSame($toggle->isChecked(), $reusable);
+
+ $reusable ? $toggle->check() : $toggle->uncheck();
+ }
+
protected function getCodeElement(): NodeElement
{
return $this->getElement('code');
@@ -50,6 +65,7 @@ protected function getDefinedElements(): array
'expires_at' => '#sylius_promotion_coupon_expiresAt',
'usage_limit' => '#sylius_promotion_coupon_usageLimit',
'per_customer_usage_limit' => '#sylius_promotion_coupon_perCustomerUsageLimit',
+ 'reusable_from_cancelled_orders' => '#sylius_promotion_coupon_reusableFromCancelledOrders',
]);
}
}
diff --git a/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePageInterface.php b/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePageInterface.php
index 9cfc8f1113b..9376fee036b 100644
--- a/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePageInterface.php
+++ b/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePageInterface.php
@@ -24,4 +24,8 @@ public function setCustomerUsageLimit(int $limit): void;
public function setExpiresAt(\DateTimeInterface $date): void;
public function setUsageLimit(string $limit): void;
+
+ public function isReusableFromCancelledOrders(): bool;
+
+ public function toggleReusableFromCancelledOrders(bool $reusable): void;
}
diff --git a/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml b/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml
index a18420f1509..2395287ecaa 100644
--- a/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml
+++ b/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml
@@ -290,5 +290,11 @@
+
+
+
+
+
+
diff --git a/src/Sylius/Behat/Resources/config/suites.yml b/src/Sylius/Behat/Resources/config/suites.yml
index ce0c23d6fd4..92c39b2c0db 100644
--- a/src/Sylius/Behat/Resources/config/suites.yml
+++ b/src/Sylius/Behat/Resources/config/suites.yml
@@ -49,6 +49,7 @@ imports:
- suites/api/promotion/applying_promotion_coupon.yml
- suites/api/promotion/applying_promotion_rules.yml
- suites/api/promotion/managing_catalog_promotions.yml
+ - suites/api/promotion/managing_promotion_coupons.yml
- suites/api/promotion/managing_promotions.yml
- suites/api/promotion/receiving_discount.yml
- suites/api/promotion/removing_catalog_promotions.yml
diff --git a/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml
new file mode 100644
index 00000000000..37509d15ec7
--- /dev/null
+++ b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml
@@ -0,0 +1,35 @@
+# This file is part of the Sylius package.
+# (c) Sylius Sp. z o.o.
+
+default:
+ suites:
+ api_managing_promotion_coupons:
+ contexts:
+ - sylius.behat.context.hook.doctrine_orm
+
+ - sylius.behat.context.transform.coupon
+ - sylius.behat.context.transform.address
+ - sylius.behat.context.transform.customer
+ - sylius.behat.context.transform.date_time
+ - sylius.behat.context.transform.lexical
+ - sylius.behat.context.transform.payment
+ - sylius.behat.context.transform.product
+ - sylius.behat.context.transform.promotion
+ - sylius.behat.context.transform.shipping_method
+ - sylius.behat.context.transform.shared_storage
+
+ - sylius.behat.context.setup.channel
+ - sylius.behat.context.setup.payment
+ - sylius.behat.context.setup.product
+ - sylius.behat.context.setup.promotion
+ - sylius.behat.context.setup.shipping
+ - sylius.behat.context.setup.order
+ - sylius.behat.context.setup.admin_api_security
+
+ - sylius.behat.context.api.admin.managing_promotion_coupons
+ - sylius.behat.context.api.admin.response
+ - sylius.behat.context.api.admin.save
+
+ filters:
+ tags: "@managing_promotion_coupons&&@api"
+ javascript: false
diff --git a/src/Sylius/Behat/Resources/config/suites/domain/promotion/managing_promotion_coupons.yml b/src/Sylius/Behat/Resources/config/suites/domain/promotion/managing_promotion_coupons.yml
index d377ae99897..460c94b06b2 100644
--- a/src/Sylius/Behat/Resources/config/suites/domain/promotion/managing_promotion_coupons.yml
+++ b/src/Sylius/Behat/Resources/config/suites/domain/promotion/managing_promotion_coupons.yml
@@ -28,6 +28,5 @@ default:
- sylius.behat.context.domain.notification
- sylius.behat.context.domain.security
-
filters:
tags: "@managing_promotion_coupons&&@domain"
diff --git a/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php b/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php
new file mode 100644
index 00000000000..efda67fdafd
--- /dev/null
+++ b/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php
@@ -0,0 +1,50 @@
+ $context */
+ public function supports($data, array $context = []): bool
+ {
+ return $data instanceof PromotionCouponInterface;
+ }
+
+ /** @param array $context */
+ public function persist($data, array $context = [])
+ {
+ return $this->decoratedDataPersister->persist($data, $context);
+ }
+
+ /** @param array $context */
+ public function remove($data, array $context = []): void
+ {
+ try {
+ $this->decoratedDataPersister->remove($data, $context);
+ } catch (ForeignKeyConstraintViolationException) {
+ throw new PromotionCouponCannotBeRemoved();
+ }
+ }
+}
diff --git a/src/Sylius/Bundle/ApiBundle/Exception/PromotionCouponCannotBeRemoved.php b/src/Sylius/Bundle/ApiBundle/Exception/PromotionCouponCannotBeRemoved.php
new file mode 100644
index 00000000000..21d84fe82b7
--- /dev/null
+++ b/src/Sylius/Bundle/ApiBundle/Exception/PromotionCouponCannotBeRemoved.php
@@ -0,0 +1,26 @@
+ $properties */
+ public function __construct(
+ private IriConverterInterface $iriConverter,
+ ManagerRegistry $managerRegistry,
+ ?RequestStack $requestStack = null,
+ LoggerInterface $logger = null,
+ array $properties = null,
+ NameConverterInterface $nameConverter = null,
+ ) {
+ parent::__construct($managerRegistry, $requestStack, $logger, $properties, $nameConverter);
+ }
+
+ protected function filterProperty(
+ string $property,
+ $value,
+ QueryBuilder $queryBuilder,
+ QueryNameGeneratorInterface $queryNameGenerator,
+ string $resourceClass,
+ string $operationName = null,
+ ): void {
+ if (self::PROPERTY !== $property) {
+ return;
+ }
+
+ $promotion = $this->iriConverter->getResourceFromIri($value);
+
+ $parameterName = $queryNameGenerator->generateParameterName(':promotion');
+ $promotionJoinAlias = $queryNameGenerator->generateJoinAlias('promotion');
+ $rootAlias = $queryBuilder->getRootAliases()[0];
+
+ $queryBuilder
+ ->innerJoin(
+ sprintf('%s.promotion', $rootAlias),
+ $promotionJoinAlias,
+ Join::WITH,
+ $queryBuilder->expr()->eq(sprintf('%s.id', $promotionJoinAlias), $parameterName),
+ )
+ ->setParameter($parameterName, $promotion)
+ ;
+ }
+
+ /** @return array */
+ public function getDescription(string $resourceClass): array
+ {
+ return [
+ self::PROPERTY => [
+ 'type' => 'string',
+ 'required' => false,
+ 'property' => null,
+ 'description' => 'Get a collection of promotion coupons for promotion',
+ 'schema' => [
+ 'type' => 'string',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Promotion.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Promotion.xml
index 4e76202e919..71c5ef7f2a7 100644
--- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Promotion.xml
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Promotion.xml
@@ -133,6 +133,13 @@ Example configuration for `order_fixed_discount` action type:
+
+
+ GET
+ /admin/promotions/{code}/promotion-coupons
+
+
+
@@ -144,7 +151,9 @@ Example configuration for `order_fixed_discount` action type:
-
+
+
+
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml
index cb50a6781c6..21cb126e1cf 100644
--- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml
@@ -18,7 +18,33 @@
admin
-
+ sylius
+
+
+
+ GET
+
+ sylius.api.promotion_coupon_order_filter
+ Sylius\Bundle\ApiBundle\Filter\Doctrine\PromotionCouponPromotionFilter
+
+
+ admin:promotion_coupon:read
+
+
+ DESC
+
+
+
+
+ POST
+
+ admin:promotion_coupon:read
+
+
+ admin:promotion_coupon:create
+
+
+
@@ -27,8 +53,39 @@
admin:promotion_coupon:read
+
+
+ PUT
+
+ admin:promotion_coupon:read
+
+
+ admin:promotion_coupon:update
+
+
+
+
+ DELETE
+
-
+
+
+
+ admin:promotion_coupon:read
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/app/config.yaml b/src/Sylius/Bundle/ApiBundle/Resources/config/app/config.yaml
index 42c3fbad41c..04acbb12754 100644
--- a/src/Sylius/Bundle/ApiBundle/Resources/config/app/config.yaml
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/app/config.yaml
@@ -61,6 +61,7 @@ api_platform:
Sylius\Bundle\ApiBundle\Exception\ProductAttributeCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProductCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProductVariantCannotBeRemoved: 422
+ Sylius\Bundle\ApiBundle\Exception\PromotionCouponCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProvinceCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ShippingMethodCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\TaxonCannotBeRemoved: 422
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml
new file mode 100644
index 00000000000..1783c72ef62
--- /dev/null
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+ admin:promotion_coupon:read
+
+
+
+ admin:promotion_coupon:read
+
+
+
+ admin:promotion_coupon:read
+ admin:promotion_coupon:create
+
+
+
+ admin:promotion_coupon:read
+ admin:promotion_coupon:create
+ admin:promotion_coupon:update
+
+
+
+ admin:promotion_coupon:read
+ admin:promotion_coupon:create
+ admin:promotion_coupon:update
+
+
+
+ admin:promotion_coupon:read
+
+
+
+ admin:promotion_coupon:read
+ admin:promotion_coupon:create
+ admin:promotion_coupon:update
+
+
+
+ admin:promotion_coupon:read
+ admin:promotion_coupon:create
+
+
+
+ admin:promotion_coupon:read
+ admin:promotion_coupon:create
+ admin:promotion_coupon:update
+
+
+
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml
index 6a54784b8c0..bc5248e1185 100644
--- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml
@@ -85,6 +85,11 @@
+
+
+
+
+
diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml
index c47196b10eb..e12e77a4368 100644
--- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml
+++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml
@@ -273,5 +273,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Sylius/Bundle/ApiBundle/spec/DataPersister/PromotionCouponDataPersisterSpec.php b/src/Sylius/Bundle/ApiBundle/spec/DataPersister/PromotionCouponDataPersisterSpec.php
new file mode 100644
index 00000000000..d1e7742ad04
--- /dev/null
+++ b/src/Sylius/Bundle/ApiBundle/spec/DataPersister/PromotionCouponDataPersisterSpec.php
@@ -0,0 +1,66 @@
+beConstructedWith($dataPersister);
+ }
+
+ function it_is_a_context_aware_persister(): void
+ {
+ $this->shouldImplement(ContextAwareDataPersisterInterface::class);
+ }
+
+ function it_supports_only_promotion_coupon(PromotionCouponInterface $coupon): void
+ {
+ $this->supports(new \stdClass())->shouldReturn(false);
+ $this->supports($coupon)->shouldReturn(true);
+ }
+
+ function it_uses_inner_persister_to_persist_promotion_coupon(
+ ContextAwareDataPersisterInterface $dataPersister,
+ PromotionCouponInterface $coupon,
+ ): void {
+ $dataPersister->persist($coupon, [])->shouldBeCalled();
+
+ $this->persist($coupon);
+ }
+
+ function it_throws_cannot_be_removed_exception_if_constraint_fails_on_removal(
+ ContextAwareDataPersisterInterface $dataPersister,
+ PromotionCouponInterface $coupon,
+ ): void {
+ $dataPersister->remove($coupon, [])->willThrow(ForeignKeyConstraintViolationException::class);
+
+ $this->shouldThrow(PromotionCouponCannotBeRemoved::class)->during('remove', [$coupon]);
+ }
+
+ function it_uses_inner_persister_to_remove_promotion_coupon(
+ ContextAwareDataPersisterInterface $dataPersister,
+ PromotionCouponInterface $coupon,
+ ): void {
+ $dataPersister->remove($coupon, [])->shouldBeCalled();
+
+ $this->remove($coupon);
+ }
+}
diff --git a/src/Sylius/Bundle/PromotionBundle/Resources/config/services/validators.xml b/src/Sylius/Bundle/PromotionBundle/Resources/config/services/validators.xml
index 42f3083e7dd..716dfa57c9c 100644
--- a/src/Sylius/Bundle/PromotionBundle/Resources/config/services/validators.xml
+++ b/src/Sylius/Bundle/PromotionBundle/Resources/config/services/validators.xml
@@ -50,5 +50,9 @@
%sylius.promotion_rules%
+
+
+
+
diff --git a/src/Sylius/Bundle/PromotionBundle/Resources/config/validation/PromotionCoupon.xml b/src/Sylius/Bundle/PromotionBundle/Resources/config/validation/PromotionCoupon.xml
index 91ed8fd9826..5b906d06b2f 100644
--- a/src/Sylius/Bundle/PromotionBundle/Resources/config/validation/PromotionCoupon.xml
+++ b/src/Sylius/Bundle/PromotionBundle/Resources/config/validation/PromotionCoupon.xml
@@ -18,6 +18,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Sylius/Bundle/PromotionBundle/Resources/translations/validators.en.yml b/src/Sylius/Bundle/PromotionBundle/Resources/translations/validators.en.yml
index 543d8f1f0e0..d2158aa2d13 100644
--- a/src/Sylius/Bundle/PromotionBundle/Resources/translations/validators.en.yml
+++ b/src/Sylius/Bundle/PromotionBundle/Resources/translations/validators.en.yml
@@ -47,6 +47,9 @@ sylius:
regex: Coupon code can only be comprised of letters, numbers, dashes and underscores.
unique: This coupon already exists.
is_invalid: Coupon code is invalid.
+ promotion:
+ not_blank: Please provide a promotion for this coupon.
+ not_coupon_based: Only coupon based promotions can have coupons.
usage_limit:
min: Coupon usage limit must be at least {{ limit }}.
promotion_coupon_generator_instruction:
diff --git a/src/Sylius/Bundle/PromotionBundle/Validator/Constraints/PromotionNotCouponBased.php b/src/Sylius/Bundle/PromotionBundle/Validator/Constraints/PromotionNotCouponBased.php
new file mode 100644
index 00000000000..8f53f5abfad
--- /dev/null
+++ b/src/Sylius/Bundle/PromotionBundle/Validator/Constraints/PromotionNotCouponBased.php
@@ -0,0 +1,31 @@
+getPromotion();
+ if (null === $promotion) {
+ return;
+ }
+
+ if (!$promotion->isCouponBased()) {
+ $this->context
+ ->buildViolation($constraint->message)
+ ->atPath('promotion')
+ ->addViolation()
+ ;
+ }
+ }
+}
diff --git a/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php b/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php
new file mode 100644
index 00000000000..b83b5229b07
--- /dev/null
+++ b/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php
@@ -0,0 +1,111 @@
+initialize($context);
+ }
+
+ function it_is_a_constraint_validator(): void
+ {
+ $this->shouldHaveType(ConstraintValidator::class);
+ }
+
+ function it_does_nothing_when_value_is_null(ExecutionContextInterface $context): void
+ {
+ $context->buildViolation(Argument::any())->shouldNotBeCalled();
+
+ $this->validate(null, new PromotionNotCouponBased());
+ }
+
+ function it_throws_an_exception_when_constraint_is_not_promotion_not_coupon_based(
+ ExecutionContextInterface $context,
+ PromotionCouponInterface $coupon,
+ Constraint $constraint,
+ ): void {
+ $context->buildViolation(Argument::any())->shouldNotBeCalled();
+
+ $this
+ ->shouldThrow(UnexpectedTypeException::class)
+ ->during('validate', [$coupon, $constraint])
+ ;
+ }
+
+ function it_throws_an_exception_when_value_is_not_a_coupon(ExecutionContextInterface $context): void
+ {
+ $context->buildViolation(Argument::any())->shouldNotBeCalled();
+
+ $this
+ ->shouldThrow(UnexpectedValueException::class)
+ ->during('validate', [new \stdClass(), new PromotionNotCouponBased()])
+ ;
+ }
+
+ function it_does_nothing_when_coupon_has_no_promotion(
+ ExecutionContextInterface $context,
+ PromotionCouponInterface $coupon,
+ ): void {
+ $context->buildViolation(Argument::any())->shouldNotBeCalled();
+
+ $coupon->getPromotion()->willReturn(null);
+
+ $this->validate($coupon, new PromotionNotCouponBased());
+ }
+
+ function it_does_nothing_when_coupon_has_promotion_and_its_coupon_based(
+ ExecutionContextInterface $context,
+ PromotionCouponInterface $coupon,
+ PromotionInterface $promotion,
+ ): void {
+ $context->buildViolation(Argument::any())->shouldNotBeCalled();
+
+ $promotion->isCouponBased()->willReturn(true);
+ $coupon->getPromotion()->willReturn($promotion);
+
+ $this->validate($coupon, new PromotionNotCouponBased());
+ }
+
+ function it_adds_violation_when_coupon_has_promotion_but_its_not_coupon_based(
+ ExecutionContextInterface $context,
+ ConstraintViolationBuilderInterface $violationBuilder,
+ PromotionCouponInterface $coupon,
+ PromotionInterface $promotion,
+ ): void {
+ $constraint = new PromotionNotCouponBased();
+
+ $context->buildViolation($constraint->message)->willReturn($violationBuilder);
+ $violationBuilder->atPath('promotion')->willReturn($violationBuilder);
+ $violationBuilder->addViolation()->shouldBeCalled();
+
+ $promotion->isCouponBased()->willReturn(false);
+ $coupon->getPromotion()->willReturn($promotion);
+
+ $this->validate($coupon, $constraint);
+ }
+}
diff --git a/tests/Api/Admin/PromotionCouponsTest.php b/tests/Api/Admin/PromotionCouponsTest.php
new file mode 100644
index 00000000000..2396a8b8e62
--- /dev/null
+++ b/tests/Api/Admin/PromotionCouponsTest.php
@@ -0,0 +1,161 @@
+loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
+ $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
+
+ /** @var PromotionCouponInterface $coupon */
+ $coupon = $fixtures['promotion_1_off_coupon_1'];
+
+ $this->client->request(
+ method: 'GET',
+ uri: sprintf('/api/v2/admin/promotion-coupons/%s', $coupon->getCode()),
+ server: $header,
+ );
+
+ $this->assertResponse(
+ $this->client->getResponse(),
+ 'admin/promotion_coupon/get_promotion_coupon_response',
+ Response::HTTP_OK,
+ );
+ }
+
+ /** @test */
+ public function it_gets_promotion_coupons(): void
+ {
+ $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
+ $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
+
+ $this->client->request(method: 'GET', uri: '/api/v2/admin/promotion-coupons', server: $header);
+
+ $this->assertResponse(
+ $this->client->getResponse(),
+ 'admin/promotion_coupon/get_promotion_coupons_response',
+ Response::HTTP_OK,
+ );
+ }
+
+ /** @test */
+ public function it_creates_a_promotion_coupon(): void
+ {
+ $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
+ $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
+
+ /** @var PromotionInterface $promotion */
+ $promotion = $fixtures['promotion_1_off'];
+
+ $this->client->request(
+ method: 'POST',
+ uri: '/api/v2/admin/promotion-coupons',
+ server: $header,
+ content: json_encode([
+ 'code' => 'XYZ3',
+ 'usageLimit' => 100,
+ 'perCustomerUsageLimit' => 3,
+ 'reusableFromCancelledOrders' => false,
+ 'expiresAt' => '23-12-2023',
+ 'promotion' => 'api/v2/admin/promotions/' . $promotion->getCode(),
+ ], JSON_THROW_ON_ERROR)
+ );
+
+ $this->assertResponse(
+ $this->client->getResponse(),
+ 'admin/promotion_coupon/post_promotion_coupon_response',
+ Response::HTTP_CREATED,
+ );
+ }
+
+ /** @test */
+ public function it_updates_a_promotion_coupon(): void
+ {
+ $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
+ $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
+
+ /** @var PromotionCouponInterface $coupon */
+ $coupon = $fixtures['promotion_1_off_coupon_1'];
+
+ $this->client->request(
+ method: 'PUT',
+ uri: '/api/v2/admin/promotion-coupons/' . $coupon->getCode(),
+ server: $header,
+ content: json_encode([
+ 'usageLimit' => 1000,
+ 'perCustomerUsageLimit' => 5,
+ 'reusableFromCancelledOrders' => false,
+ 'expiresAt' => '2020-01-01 12:00:00',
+ ], JSON_THROW_ON_ERROR)
+ );
+
+ $this->assertResponse(
+ $this->client->getResponse(),
+ 'admin/promotion_coupon/put_promotion_coupon_response',
+ Response::HTTP_OK,
+ );
+ }
+
+ /** @test */
+ public function it_removes_a_promotion_coupon(): void
+ {
+ $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
+ $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
+
+ /** @var PromotionCouponInterface $coupon */
+ $coupon = $fixtures['promotion_1_off_coupon_1'];
+
+ $this->client->request(
+ method: 'DELETE',
+ uri: '/api/v2/admin/promotion-coupons/' . $coupon->getCode(),
+ server: $header,
+ );
+
+ $this->assertResponseCode($this->client->getResponse(), Response::HTTP_NO_CONTENT);
+ }
+
+ /** @test */
+ public function it_does_not_delete_the_promotion_coupon_in_use(): void
+ {
+ $fixtures = $this->loadFixturesFromFiles([
+ 'authentication/api_administrator.yaml',
+ 'channel.yaml',
+ 'promotion/promotion.yaml',
+ 'promotion/promotion_order.yaml',
+ ]);
+ $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
+
+ /** @var PromotionCouponInterface $coupon */
+ $coupon = $fixtures['promotion_1_off_coupon_1'];
+
+ $this->client->request(
+ method: 'DELETE',
+ uri: '/api/v2/admin/promotion-coupons/' . $coupon->getCode(),
+ server: $header,
+ );
+
+ $this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+}
diff --git a/tests/Api/Admin/PromotionsTest.php b/tests/Api/Admin/PromotionsTest.php
index b86f1e8dfde..c3585c04b15 100644
--- a/tests/Api/Admin/PromotionsTest.php
+++ b/tests/Api/Admin/PromotionsTest.php
@@ -72,6 +72,28 @@ public function it_gets_promotions(): void
);
}
+ /** @test */
+ public function it_gets_promotion_coupons(): void
+ {
+ $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
+ $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
+
+ /** @var PromotionInterface $promotion */
+ $promotion = $fixtures['promotion_1_off'];
+
+ $this->client->request(
+ method: 'GET',
+ uri: sprintf('/api/v2/admin/promotions/%s/promotion-coupons', $promotion->getCode()),
+ server: $header
+ );
+
+ $this->assertResponse(
+ $this->client->getResponse(),
+ 'admin/promotion/get_promotion_coupons_response',
+ Response::HTTP_OK,
+ );
+ }
+
/** @test */
public function it_creates_promotion(): void
{
diff --git a/tests/Api/DataFixtures/ORM/promotion/promotion.yaml b/tests/Api/DataFixtures/ORM/promotion/promotion.yaml
index 7749bac08b5..bb4496f4ba2 100644
--- a/tests/Api/DataFixtures/ORM/promotion/promotion.yaml
+++ b/tests/Api/DataFixtures/ORM/promotion/promotion.yaml
@@ -33,3 +33,20 @@ Sylius\Component\Promotion\Model\PromotionTranslation:
locale: 'en_US'
label: '1$ off every item!'
translatable: '@promotion_1_off'
+
+Sylius\Component\Core\Model\PromotionCoupon:
+ promotion_1_off_coupon_1:
+ code: 'XYZ1'
+ usageLimit: 2
+ perCustomerUsageLimit: 1
+ used: 1
+ reusableFromCancelledOrders: true
+ promotion: '@promotion_1_off'
+ expiresAt: null
+ promotion_1_off_coupon_2:
+ code: 'XYZ2'
+ usageLimit: null
+ perCustomerUsageLimit: null
+ reusableFromCancelledOrders: false
+ promotion: '@promotion_1_off'
+ expiresAt:
diff --git a/tests/Api/DataFixtures/ORM/promotion/promotion_order.yaml b/tests/Api/DataFixtures/ORM/promotion/promotion_order.yaml
index 6a7fd111ec0..c920d549528 100644
--- a/tests/Api/DataFixtures/ORM/promotion/promotion_order.yaml
+++ b/tests/Api/DataFixtures/ORM/promotion/promotion_order.yaml
@@ -6,3 +6,4 @@ Sylius\Component\Core\Model\Order:
state: "new"
tokenValue: "token"
promotions: ['@promotion_50_off']
+ promotionCoupon: '@promotion_1_off_coupon_1'
diff --git a/tests/Api/Responses/Expected/admin/promotion/get_promotion_coupons_response.json b/tests/Api/Responses/Expected/admin/promotion/get_promotion_coupons_response.json
new file mode 100644
index 00000000000..cd555764d0b
--- /dev/null
+++ b/tests/Api/Responses/Expected/admin/promotion/get_promotion_coupons_response.json
@@ -0,0 +1,34 @@
+{
+ "@context": "\/api\/v2\/contexts\/PromotionCoupon",
+ "@id": "\/api\/v2\/admin\/promotions/dollar_off/promotion-coupons",
+ "@type": "hydra:Collection",
+ "hydra:member": [
+ {
+ "@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
+ "@type": "PromotionCoupon",
+ "perCustomerUsageLimit": 1,
+ "reusableFromCancelledOrders": true,
+ "code": "XYZ1",
+ "usageLimit": 2,
+ "used": 1,
+ "promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
+ "expiresAt": null,
+ "createdAt": @date@,
+ "updatedAt": @date@
+ },
+ {
+ "@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ2",
+ "@type": "PromotionCoupon",
+ "perCustomerUsageLimit": null,
+ "reusableFromCancelledOrders": false,
+ "code": "XYZ2",
+ "usageLimit": null,
+ "used": 0,
+ "promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
+ "expiresAt": @date@,
+ "createdAt": @date@,
+ "updatedAt": @date@
+ }
+ ],
+ "hydra:totalItems": 2
+}
diff --git a/tests/Api/Responses/Expected/admin/promotion/get_promotion_response.json b/tests/Api/Responses/Expected/admin/promotion/get_promotion_response.json
index 59910304922..457730d357d 100644
--- a/tests/Api/Responses/Expected/admin/promotion/get_promotion_response.json
+++ b/tests/Api/Responses/Expected/admin/promotion/get_promotion_response.json
@@ -6,7 +6,9 @@
"code": "50_off",
"name": "50% Off on your first order",
"description": "Get 50% off of your first purchase",
- "channels": ["\/api\/v2\/admin\/channels\/MOBILE"],
+ "channels": [
+ "\/api\/v2\/admin\/channels\/MOBILE"
+ ],
"priority": 1,
"exclusive": true,
"appliesToDiscounted": false,
diff --git a/tests/Api/Responses/Expected/admin/promotion/get_promotions_response.json b/tests/Api/Responses/Expected/admin/promotion/get_promotions_response.json
index 42e62fd1705..1b267930390 100644
--- a/tests/Api/Responses/Expected/admin/promotion/get_promotions_response.json
+++ b/tests/Api/Responses/Expected/admin/promotion/get_promotions_response.json
@@ -10,7 +10,10 @@
"code": "dollar_off",
"name": "1 dollar off each item",
"description": "Get 1 dollar off every item when buying more than 10",
- "channels": ["\/api\/v2\/admin\/channels\/WEB", "\/api\/v2\/admin\/channels\/MOBILE"],
+ "channels": [
+ "\/api\/v2\/admin\/channels\/WEB",
+ "\/api\/v2\/admin\/channels\/MOBILE"
+ ],
"priority": 2,
"exclusive": false,
"appliesToDiscounted": true,
@@ -19,7 +22,10 @@
"couponBased": true,
"startsAt": null,
"endsAt": null,
- "coupons": [],
+ "coupons": [
+ "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
+ "\/api\/v2\/admin\/promotion-coupons\/XYZ2"
+ ],
"rules": [],
"actions": [],
"createdAt": @date@,
@@ -40,7 +46,9 @@
"code": "50_off",
"name": "50% Off on your first order",
"description": "Get 50% off of your first purchase",
- "channels": ["\/api\/v2\/admin\/channels\/MOBILE"],
+ "channels": [
+ "\/api\/v2\/admin\/channels\/MOBILE"
+ ],
"priority": 1,
"exclusive": true,
"appliesToDiscounted": false,
@@ -67,7 +75,7 @@
"hydra:totalItems": 2,
"hydra:search": {
"@type": "hydra:IriTemplate",
- "hydra:template": "/api/v2/admin/promotions{?coupons.code,coupons.code[],order[priority]}",
+ "hydra:template": "\/api\/v2\/admin\/promotions{?coupons.code,coupons.code[],order[priority]}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupon_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupon_response.json
new file mode 100644
index 00000000000..0356364fdc8
--- /dev/null
+++ b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupon_response.json
@@ -0,0 +1,14 @@
+{
+ "@context": "\/api\/v2\/contexts\/PromotionCoupon",
+ "@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
+ "@type": "PromotionCoupon",
+ "code": "XYZ1",
+ "usageLimit": 2,
+ "perCustomerUsageLimit": 1,
+ "used": 1,
+ "reusableFromCancelledOrders": true,
+ "promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
+ "createdAt": @date@,
+ "updatedAt": @date@,
+ "expiresAt": null
+}
diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json
new file mode 100644
index 00000000000..237bffff856
--- /dev/null
+++ b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json
@@ -0,0 +1,77 @@
+{
+ "@context": "\/api\/v2\/contexts\/PromotionCoupon",
+ "@id": "\/api\/v2\/admin\/promotion-coupons",
+ "@type": "hydra:Collection",
+ "hydra:member": [
+ {
+ "@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
+ "@type": "PromotionCoupon",
+ "perCustomerUsageLimit": 1,
+ "reusableFromCancelledOrders": true,
+ "code": "XYZ1",
+ "usageLimit": 2,
+ "used": 1,
+ "promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
+ "expiresAt": null,
+ "createdAt": @date@,
+ "updatedAt": @date@
+ },
+ {
+ "@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ2",
+ "@type": "PromotionCoupon",
+ "perCustomerUsageLimit": null,
+ "reusableFromCancelledOrders": false,
+ "code": "XYZ2",
+ "usageLimit": null,
+ "used": 0,
+ "promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
+ "expiresAt": @date@,
+ "createdAt": @date@,
+ "updatedAt": @date@
+ }
+ ],
+ "hydra:totalItems": 2,
+ "hydra:search": {
+ "@type": "hydra:IriTemplate",
+ "hydra:template": "\/api\/v2\/admin\/promotion-coupons{?order[code],order[expiresAt],order[usageLimit],order[perCustomerUsageLimit],order[used],promotion}",
+ "hydra:variableRepresentation": "BasicRepresentation",
+ "hydra:mapping": [
+ {
+ "@type": "IriTemplateMapping",
+ "variable": "order[code]",
+ "property": "code",
+ "required": false
+ },
+ {
+ "@type": "IriTemplateMapping",
+ "variable": "order[expiresAt]",
+ "property": "expiresAt",
+ "required": false
+ },
+ {
+ "@type": "IriTemplateMapping",
+ "variable": "order[usageLimit]",
+ "property": "usageLimit",
+ "required": false
+ },
+ {
+ "@type": "IriTemplateMapping",
+ "variable": "order[perCustomerUsageLimit]",
+ "property": "perCustomerUsageLimit",
+ "required": false
+ },
+ {
+ "@type": "IriTemplateMapping",
+ "variable": "order[used]",
+ "property": "used",
+ "required": false
+ },
+ {
+ "@type": "IriTemplateMapping",
+ "variable": "promotion",
+ "property": null,
+ "required": false
+ }
+ ]
+ }
+}
diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json
new file mode 100644
index 00000000000..8542e47a235
--- /dev/null
+++ b/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json
@@ -0,0 +1,14 @@
+{
+ "@context": "\/api\/v2\/contexts\/PromotionCoupon",
+ "@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ3",
+ "@type": "PromotionCoupon",
+ "code": "XYZ3",
+ "usageLimit": 100,
+ "perCustomerUsageLimit": 3,
+ "used": 0,
+ "reusableFromCancelledOrders": false,
+ "promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
+ "createdAt": @date@,
+ "updatedAt": @date@,
+ "expiresAt": @date@
+}
diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/put_promotion_coupon_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/put_promotion_coupon_response.json
new file mode 100644
index 00000000000..51cdf78a1bd
--- /dev/null
+++ b/tests/Api/Responses/Expected/admin/promotion_coupon/put_promotion_coupon_response.json
@@ -0,0 +1,14 @@
+{
+ "@context": "\/api\/v2\/contexts\/PromotionCoupon",
+ "@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
+ "@type": "PromotionCoupon",
+ "code": "XYZ1",
+ "usageLimit": 1000,
+ "perCustomerUsageLimit": 5,
+ "used": 1,
+ "reusableFromCancelledOrders": false,
+ "promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
+ "createdAt": @date@,
+ "updatedAt": @date@,
+ "expiresAt": "2020-01-01 12:00:00"
+}