From 9f0e2be042ebe8bf59bcc63146262525185475ea Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Mon, 18 Nov 2024 16:53:30 +0000 Subject: [PATCH 1/6] Add coupon code condition rule and element query param --- CHANGELOG-WIP.md | 12 ++++- .../orders/CouponCodeConditionRule.php | 29 +++++++++++ .../conditions/orders/OrderCondition.php | 3 +- src/elements/db/OrderQuery.php | 52 +++++++++++++++++++ tests/unit/elements/order/OrderQueryTest.php | 39 ++++++++++++++ 5 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 src/elements/conditions/orders/CouponCodeConditionRule.php diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index de991b6ce5..ce8be4ab44 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1,5 +1,13 @@ # Release Notes for Craft Commerce (WIP) -## Administration +### Administration +- Added a new "Coupon Code" order condition rule. ([#3776](https://github.com/craftcms/commerce/discussions/3776)) +- Added a new "Payment Gateway" order condition rule. ([#3722](https://github.com/craftcms/commerce/discussions/3722)) -- Added a new "Payment Gateway" order condition rule. ([#3722](https://github.com/craftcms/commerce/discussions/3722)) \ No newline at end of file +### Development +- Added the `couponCode` order query param. + +### Extensibility +- Added `craft\commerce\elements\conditions\orders\CouponCodeConditionRule`. +- Added `craft\commerce\elements\db\OrderQuery::$couponCode`. +- Added `craft\commerce\elements\db\OrderQuery::couponCode()`. \ No newline at end of file diff --git a/src/elements/conditions/orders/CouponCodeConditionRule.php b/src/elements/conditions/orders/CouponCodeConditionRule.php new file mode 100644 index 0000000000..e865619e48 --- /dev/null +++ b/src/elements/conditions/orders/CouponCodeConditionRule.php @@ -0,0 +1,29 @@ + + * @since 5.3.0 + */ +class CouponCodeConditionRule extends OrderTextValuesAttributeConditionRule +{ + public string $orderAttribute = 'couponCode'; + + /** + * @inheritdoc + */ + public function getLabel(): string + { + return Craft::t('commerce', 'Coupon Code'); + } +} diff --git a/src/elements/conditions/orders/OrderCondition.php b/src/elements/conditions/orders/OrderCondition.php index dd9e894c1c..15330cc8d0 100644 --- a/src/elements/conditions/orders/OrderCondition.php +++ b/src/elements/conditions/orders/OrderCondition.php @@ -30,8 +30,9 @@ protected function selectableConditionRules(): array { return array_merge(parent::selectableConditionRules(), [ DateOrderedConditionRule::class, - CustomerConditionRule::class, CompletedConditionRule::class, + CouponCodeConditionRule::class, + CustomerConditionRule::class, PaidConditionRule::class, HasPurchasableConditionRule::class, ItemSubtotalConditionRule::class, diff --git a/src/elements/db/OrderQuery.php b/src/elements/db/OrderQuery.php index a48d3b2626..cb1794e4a8 100644 --- a/src/elements/db/OrderQuery.php +++ b/src/elements/db/OrderQuery.php @@ -59,6 +59,12 @@ class OrderQuery extends ElementQuery */ public mixed $reference = null; + /** + * @var mixed The order reference of the resulting order. + * @used-by couponCode() + */ + public mixed $couponCode = null; + /** * @var mixed The email address the resulting orders must have. */ @@ -372,6 +378,48 @@ public function reference(mixed $value): OrderQuery return $this; } + /** + * Narrows the query results based on the order's coupon code. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `':empty:'` | that don’t have a coupon code. + * | `':notempty:'` | that have a coupon code. + * | `'Foo'` | with a coupon code of `Foo`. + * | `'Foo*'` | with a coupon code that begins with `Foo`. + * | `'*Foo'` | with a coupon code that ends with `Foo`. + * | `'*Foo*'` | with a coupon code that contains `Foo`. + * | `'not *Foo*'` | with a coupon code that doesn’t contain `Foo`. + * | `['*Foo*', '*Bar*']` | with a coupon code that contains `Foo` or `Bar`. + * | `['not', '*Foo*', '*Bar*']` | with a coupon code that doesn’t contain `Foo` or `Bar`. + * + * --- + * + * ```twig + * {# Fetch the requested {element} #} + * {% set {element-var} = {twig-method} + * .reference('foo') + * .one() %} + * ``` + * + * ```php + * // Fetch the requested {element} + * ${element-var} = {php-method} + * ->reference('foo') + * ->one(); + * ``` + * + * @param string|null $value The property value + * @return static self reference + */ + public function couponCode(mixed $value): OrderQuery + { + $this->couponCode = $value; + return $this; + } + /** * Narrows the query results based on the customers’ email addresses. * @@ -1603,6 +1651,10 @@ protected function beforePrepare(): bool $this->subQuery->andWhere(Db::parseParam('commerce_orders.reference', $this->reference)); } + if (isset($this->couponCode)) { + $this->subQuery->andWhere(Db::parseParam('commerce_orders.couponCode', $this->couponCode)); + } + if (isset($this->email) && $this->email) { // Join and search the users table for email address $this->subQuery->leftJoin(CraftTable::USERS . ' users', '[[users.id]] = [[commerce_orders.customerId]]'); diff --git a/tests/unit/elements/order/OrderQueryTest.php b/tests/unit/elements/order/OrderQueryTest.php index 2c06a75e6c..9c6720864e 100644 --- a/tests/unit/elements/order/OrderQueryTest.php +++ b/tests/unit/elements/order/OrderQueryTest.php @@ -63,6 +63,45 @@ public function emailDataProvider(): array ]; } + /** + * @param string $couponCode + * @param int $count + * @return void + * @dataProvider couponCodeDataProvider + */ + public function testCouponCode(?string $couponCode, int $count): void + { + $ordersFixture = $this->tester->grabFixture('orders'); + /** @var Order $order */ + $order = $ordersFixture->getElement('completed-new'); + + // Temporarily add a coupon code to an order + \craft\commerce\records\Order::updateAll(['couponCode' => 'foo'], ['id' => $order->id]); + + $orderQuery = Order::find(); + $orderQuery->couponCode($couponCode); + + self::assertCount($count, $orderQuery->all()); + + // Remove temporary coupon code + \craft\commerce\records\Order::updateAll(['couponCode' => null], ['id' => $order->id]); + } + + /** + * @return array[] + */ + public function couponCodeDataProvider(): array + { + return [ + 'normal' => ['foo', 1], + 'case-insensitive' => ['fOo', 1], + 'using-null' => [null, 3], + 'empty-code' => [':empty:', 2], + 'not-empty-code' => [':notempty:', 1], + 'no-results' => ['nope', 0], + ]; + } + /** * @param mixed $handle * @param int $count From ebf639bd45569fe7b5e9a3ad2a259e193ac47efd Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 19 Nov 2024 09:05:00 +0000 Subject: [PATCH 2/6] Fix order query coupon code, should be case-insensitive --- src/elements/db/OrderQuery.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/elements/db/OrderQuery.php b/src/elements/db/OrderQuery.php index cb1794e4a8..18de59bf54 100644 --- a/src/elements/db/OrderQuery.php +++ b/src/elements/db/OrderQuery.php @@ -1652,7 +1652,8 @@ protected function beforePrepare(): bool } if (isset($this->couponCode)) { - $this->subQuery->andWhere(Db::parseParam('commerce_orders.couponCode', $this->couponCode)); + // Coupon code criteria is case-insensitive like in the adjuster + $this->subQuery->andWhere(Db::parseParam('commerce_orders.couponCode', $this->couponCode, caseInsensitive: true)); } if (isset($this->email) && $this->email) { From 664fc0f2fb6daf6566b66556da5dd0f7fec4c7fc Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 19 Nov 2024 14:18:42 +0000 Subject: [PATCH 3/6] `CouponCodeConditionRule` re-implements `matchValue()` method to be case insensitive --- .../orders/CouponCodeConditionRule.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/elements/conditions/orders/CouponCodeConditionRule.php b/src/elements/conditions/orders/CouponCodeConditionRule.php index e865619e48..a6c231899f 100644 --- a/src/elements/conditions/orders/CouponCodeConditionRule.php +++ b/src/elements/conditions/orders/CouponCodeConditionRule.php @@ -8,6 +8,8 @@ namespace craft\commerce\elements\conditions\orders; use Craft; +use craft\helpers\StringHelper; +use yii\base\InvalidConfigException; /** * Order Coupon Code condition rule. @@ -26,4 +28,30 @@ public function getLabel(): string { return Craft::t('commerce', 'Coupon Code'); } + + /** + * @inheritdoc + */ + protected function matchValue(mixed $value): bool + { + switch ($this->operator) { + case self::OPERATOR_EMPTY: + return !$value; + case self::OPERATOR_NOT_EMPTY: + return (bool)$value; + } + + if ($this->value === '') { + return true; + } + + return match ($this->operator) { + self::OPERATOR_EQ => strcasecmp($value, $this->value) === 0, + self::OPERATOR_NE => strcasecmp($value, $this->value) !== 0, + self::OPERATOR_BEGINS_WITH => is_string($value) && StringHelper::startsWith($value, $this->value, false), + self::OPERATOR_ENDS_WITH => is_string($value) && StringHelper::endsWith($value, $this->value, false), + self::OPERATOR_CONTAINS => is_string($value) && StringHelper::contains($value, $this->value, false), + default => throw new InvalidConfigException("Invalid operator: $this->operator"), + }; + } } From f3720592497ca56db38455ba609f18c00d3d9a76 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 19 Nov 2024 14:20:28 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Make=20sure=20the=20discount=20order=20cond?= =?UTF-8?q?ition=20doesn=E2=80=99t=20have=20the=20coupon=20code=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/elements/conditions/orders/DiscountOrderCondition.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/elements/conditions/orders/DiscountOrderCondition.php b/src/elements/conditions/orders/DiscountOrderCondition.php index 14c7a730ad..4b35288076 100644 --- a/src/elements/conditions/orders/DiscountOrderCondition.php +++ b/src/elements/conditions/orders/DiscountOrderCondition.php @@ -5,6 +5,7 @@ use craft\commerce\base\HasStoreInterface; use craft\commerce\base\StoreTrait; use craft\elements\db\ElementQueryInterface; +use craft\helpers\ArrayHelper; use yii\base\NotSupportedException; /** @@ -41,7 +42,12 @@ protected function config(): array */ protected function selectableConditionRules(): array { - return array_merge(parent::selectableConditionRules(), []); + $rules = array_merge(parent::selectableConditionRules(), []); + + // We don't need the condition to have the coupon code rule + ArrayHelper::removeValue($rules, CouponCodeConditionRule::class); + + return $rules; } /** From 597f93a77f02143c72025795eae8a86b83ea92cc Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 19 Nov 2024 14:27:06 +0000 Subject: [PATCH 5/6] Add `CouponCodeConditionRule` to `OrderConditionTest` --- tests/unit/elements/order/conditions/OrderConditionTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/elements/order/conditions/OrderConditionTest.php b/tests/unit/elements/order/conditions/OrderConditionTest.php index b040a3cc97..fd5cdec51f 100644 --- a/tests/unit/elements/order/conditions/OrderConditionTest.php +++ b/tests/unit/elements/order/conditions/OrderConditionTest.php @@ -9,6 +9,7 @@ use Codeception\Test\Unit; use craft\commerce\elements\conditions\orders\CompletedConditionRule; +use craft\commerce\elements\conditions\orders\CouponCodeConditionRule; use craft\commerce\elements\conditions\orders\CustomerConditionRule; use craft\commerce\elements\conditions\orders\DateOrderedConditionRule; use craft\commerce\elements\conditions\orders\HasPurchasableConditionRule; @@ -66,8 +67,9 @@ public function testConditionRuleTypes(): void $rules = array_keys($rules); self::assertContains(DateOrderedConditionRule::class, $rules); - self::assertContains(CustomerConditionRule::class, $rules); self::assertContains(CompletedConditionRule::class, $rules); + self::assertContains(CouponCodeConditionRule::class, $rules); + self::assertContains(CustomerConditionRule::class, $rules); self::assertContains(PaidConditionRule::class, $rules); self::assertContains(HasPurchasableConditionRule::class, $rules); self::assertContains(ItemSubtotalConditionRule::class, $rules); From 609551c66d12cf0ce43eab16823fac95dbf8bfaa Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 19 Nov 2024 14:27:21 +0000 Subject: [PATCH 6/6] Add `CouponCodeConditionRuleTest` --- .../CouponCodeConditionRuleTest.php | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php diff --git a/tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php b/tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php new file mode 100644 index 0000000000..1905ef5bb6 --- /dev/null +++ b/tests/unit/elements/order/conditions/CouponCodeConditionRuleTest.php @@ -0,0 +1,162 @@ + + * @since 5.3.0 + */ +class CouponCodeConditionRuleTest extends Unit +{ + /** + * @return array + */ + public function _fixtures(): array + { + return [ + 'orders' => [ + 'class' => OrdersFixture::class, + ], + ]; + } + + /** + * @group Order + * @dataProvider matchElementDataProvider + */ + public function testMatchElement(?string $coupon, string $operator = '=', ?string $orderCoupon = null, bool $expectedMatch = true): void + { + $condition = $this->_createCondition($coupon, $operator); + + $ordersFixture = $this->tester->grabFixture('orders'); + /** @var Order $order */ + $order = $ordersFixture->getElement('completed-new'); + + if ($orderCoupon) { + $order->couponCode = $orderCoupon; + } + + $match = $condition->matchElement($order); + + if ($expectedMatch) { + self::assertTrue($match); + } else { + self::assertFalse($match); + } + } + + /** + * @return array[] + */ + public function matchElementDataProvider(): array + { + return [ + 'match-equals' => ['coupon1', '=', 'coupon1', true], + 'match-equals-case-insensitive' => ['coupon1', '=', 'cOuPoN1', true], + 'no-match-equals' => ['coupon1', '=', 'coupon2', false], + 'no-match-equals-case-insensitive' => ['coupon1', '=', 'cOuPoN2', false], + 'no-match-equals-null' => ['coupon1', '=', null, false], + 'match-contains' => ['coupon1', '**', 'coupon1', true], + 'match-contains-case-insensitive' => ['coupon1', '**', 'cOuPoN1', true], + 'no-match-contains' => ['coupon1', '**', 'coupon2', false], + 'no-match-contains-case-insensitive' => ['coupon1', '**', 'cOuPoN2', false], + 'match-begins-with' => ['coupon', 'bw', 'coupon1', true], + 'match-begins-with-case-insensitive' => ['coupon', 'bw', 'cOuPoN1', true], + 'no-match-begins-with' => ['coupon', 'bw', 'foocoupon2', false], + 'no-match-begins-with-case-insensitive' => ['coupon', 'bw', 'foocOuPoN2', false], + 'match-ends-with' => ['pon1', 'ew', 'coupon1', true], + 'match-ends-with-case-insensitive' => ['pon1', 'ew', 'cOuPoN1', true], + 'no-match-ends-with' => ['pon2', 'ew', 'coupon2foo', false], + 'no-match-ends-with-case-insensitive' => ['pon2', 'ew', 'cOuPoN2foo', false], + ]; + } + + /** + * @group Order + * @dataProvider modifyQueryDataProvider + */ + public function testModifyQuery(?string $coupon, string $operator = '=', ?string $orderCoupon = null, int $expectedResults = 0): void + { + $condition = $this->_createCondition($coupon, $operator); + $orderFixture = $this->tester->grabFixture('orders'); + /** @var Order $order */ + $order = $orderFixture->getElement('completed-new'); + + // Temporarily add a coupon code to an order + \craft\commerce\records\Order::updateAll(['couponCode' => $orderCoupon], ['id' => $order->id]); + + $query = Order::find(); + $condition->modifyQuery($query); + + self::assertCount($expectedResults, $query->ids()); + + if ($expectedResults > 0) { + self::assertContainsEquals($order->id, $query->ids()); + } else { + self::assertEmpty($query->ids()); + } + + // Remove temporary coupon code + \craft\commerce\records\Order::updateAll(['couponCode' => null], ['id' => $order->id]); + } + + /** + * @return array[] + */ + public function modifyQueryDataProvider(): array + { + return [ + 'match-equals' => ['coupon1', '=', 'coupon1', 1], + 'match-equals-case-insensitive' => ['coupon1', '=', 'cOuPoN1', 1], + 'no-match-equals' => ['coupon1', '=', 'coupon2', 0], + 'no-match-equals-case-insensitive' => ['coupon1', '=', 'cOuPoN2', 0], + 'no-match-equals-null' => ['coupon1', '=', null, 0], + 'match-contains' => ['coupon1', '**', 'coupon1', 1], + 'match-contains-case-insensitive' => ['coupon1', '**', 'cOuPoN1', 1], + 'no-match-contains' => ['coupon1', '**', 'coupon2', 0], + 'no-match-contains-case-insensitive' => ['coupon1', '**', 'cOuPoN2', 0], + 'match-begins-with' => ['coupon', 'bw', 'coupon1', 1], + 'match-begins-with-case-insensitive' => ['coupon', 'bw', 'cOuPoN1', 1], + 'no-match-begins-with' => ['coupon', 'bw', 'foocoupon2', 0], + 'no-match-begins-with-case-insensitive' => ['coupon', 'bw', 'foocOuPoN2', 0], + 'match-ends-with' => ['pon1', 'ew', 'coupon1', 1], + 'match-ends-with-case-insensitive' => ['pon1', 'ew', 'cOuPoN1', 1], + 'no-match-ends-with' => ['pon2', 'ew', 'coupon2foo', 0], + 'no-match-ends-with-case-insensitive' => ['pon2', 'ew', 'cOuPoN2foo', 0], + ]; + } + + /** + * @param string|null $value + * @param string|null $operator + * @return OrderCondition + */ + private function _createCondition(?string $value, ?string $operator = null): OrderCondition + { + $condition = Order::createCondition(); + /** @var CouponCodeConditionRule $rule */ + $rule = \Craft::$app->getConditions()->createConditionRule(CouponCodeConditionRule::class); + $rule->value = $value; + + if ($operator) { + $rule->operator = $operator; + } + + $condition->addConditionRule($rule); + + return $condition; + } +}