diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e2af5265..fd0bfb3a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,9 +7,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 strategy: + fail-fast: false matrix: php: ['8.2', '8.3'] - laravel: ['10.43', '10.48', '11.0', '11.17'] + laravel: ['10.43', '10.48', '11.0', '11.14'] name: PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} steps: - name: Checkout @@ -27,22 +28,22 @@ jobs: - name: Create SQLite Database run: mkdir -p database && touch database/database.sqlite - name: Run Tests [sqlite] - run: php vendor/bin/phpunit --testdox + run: php vendor/bin/phpunit --stop-on-defect --testdox env: TEST_DB_ENGINE: sqlite - name: Run Tests [postgres] - run: php vendor/bin/phpunit --testdox + run: php vendor/bin/phpunit --stop-on-defect --testdox env: TEST_DB_ENGINE: pgsql TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }} TEST_DB_PASSWORD: postgres - name: Run Tests [mysql 5.7] - run: php vendor/bin/phpunit --testdox + run: php vendor/bin/phpunit --stop-on-defect --testdox env: TEST_DB_ENGINE: mysql TEST_DB_PORT: ${{ job.services.mysql.ports[3306] }} - name: Run Tests [mysql 8.0] - run: php vendor/bin/phpunit --testdox + run: php vendor/bin/phpunit --stop-on-defect --testdox env: TEST_DB_ENGINE: mysql TEST_DB_PORT: ${{ job.services.mysql8.ports[3306] }} diff --git a/build-tools/release.sh b/build-tools/release.sh index 6fbf6ce7..d1539d2c 100755 --- a/build-tools/release.sh +++ b/build-tools/release.sh @@ -49,7 +49,7 @@ git tag $VERSION git push origin --tags # Tag Components -for REMOTE in adjustments cart category channel checkout contracts links master-product order payment product properties shipment support taxes +for REMOTE in adjustments cart category channel checkout contracts links master-product order payment product promotion properties shipment support taxes do echo "" echo "" diff --git a/build-tools/split.sh b/build-tools/split.sh index 6c40e86e..30e47a3f 100755 --- a/build-tools/split.sh +++ b/build-tools/split.sh @@ -35,6 +35,7 @@ remote master-product remote order remote payment remote product +remote promotion remote properties remote shipment remote support @@ -51,6 +52,7 @@ split 'src/MasterProduct' master-product split 'src/Order' order split 'src/Payment' payment split 'src/Product' product +split 'src/Promotion' promotion split 'src/Properties' properties split 'src/Shipment' shipment split 'src/Support' support 8683e47dd2dbd15ac2ceac4dcfae405c4b271aff diff --git a/composer.json b/composer.json index ce881336..f33cc814 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "laravel/framework": "^10.43|^11.0", "konekt/enum": "^4.2", "konekt/concord": "^1.13", + "konekt/xtend": "^1.2", "spatie/laravel-medialibrary": "^11.0", "cviebrock/eloquent-sluggable": "^10.0|^11.0", "konekt/laravel-migration-compatibility": "^1.6", @@ -42,6 +43,7 @@ "vanilo/order": "self.version", "vanilo/payment": "self.version", "vanilo/product": "self.version", + "vanilo/promotion": "self.version", "vanilo/properties": "self.version", "vanilo/shipment": "self.version", "vanilo/support": "self.version", diff --git a/module-status.md b/module-status.md index 8c5a1d04..9ebe1868 100644 --- a/module-status.md +++ b/module-status.md @@ -17,6 +17,7 @@ | Order | [![Tests](https://img.shields.io/github/actions/workflow/status/vanilophp/order/tests.yml?branch=master&style=flat-square)](https://github.com/vanilophp/order/actions?query=workflow%3Atests) | [![Packagist version](https://img.shields.io/packagist/v/vanilo/order.svg?style=flat-square&include_prereleases)](https://packagist.org/packages/vanilo/order) | | Payment | [![Tests](https://img.shields.io/github/actions/workflow/status/vanilophp/payment/tests.yml?branch=master&style=flat-square)](https://github.com/vanilophp/payment/actions?query=workflow%3Atests) | [![Packagist version](https://img.shields.io/packagist/v/vanilo/payment.svg?style=flat-square&include_prereleases)](https://packagist.org/packages/vanilo/payment) | | Product | [![Tests](https://img.shields.io/github/actions/workflow/status/vanilophp/product/tests.yml?branch=master&style=flat-square)](https://github.com/vanilophp/product/actions?query=workflow%3Atests) | [![Packagist version](https://img.shields.io/packagist/v/vanilo/product.svg?style=flat-square&include_prereleases)](https://packagist.org/packages/vanilo/product) | +| Promotion | [![Tests](https://img.shields.io/github/actions/workflow/status/vanilophp/promotion/tests.yml?branch=master&style=flat-square)](https://github.com/vanilophp/promotion/actions?query=workflow%3Atests) | [![Packagist version](https://img.shields.io/packagist/v/vanilo/promotion.svg?style=flat-square&include_prereleases)](https://packagist.org/packages/vanilo/promotion) | | Properties | [![Tests](https://img.shields.io/github/actions/workflow/status/vanilophp/properties/tests.yml?branch=master&style=flat-square)](https://github.com/vanilophp/properties/actions?query=workflow%3Atests) | [![Packagist version](https://img.shields.io/packagist/v/vanilo/properties.svg?style=flat-square&include_prereleases)](https://packagist.org/packages/vanilo/properties) | | Shipment | [![Tests](https://img.shields.io/github/actions/workflow/status/vanilophp/shipment/tests.yml?branch=master&style=flat-square)](https://github.com/vanilophp/shipment/actions?query=workflow%3Atests) | [![Packagist version](https://img.shields.io/packagist/v/vanilo/shipment.svg?style=flat-square&include_prereleases)](https://packagist.org/packages/vanilo/shipment) | | Support | [![Tests](https://img.shields.io/github/actions/workflow/status/vanilophp/support/tests.yml?branch=master&style=flat-square)](https://github.com/vanilophp/support/actions?query=workflow%3Atests) | [![Packagist version](https://img.shields.io/packagist/v/vanilo/support.svg?style=flat-square&include_prereleases)](https://packagist.org/packages/vanilo/support) | diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5f25e655..02e6195c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,6 +19,9 @@ src/Properties/Tests + + src/Promotion/Tests + src/Category/Tests diff --git a/src/Promotion/.gitattributes b/src/Promotion/.gitattributes new file mode 100644 index 00000000..777b69dd --- /dev/null +++ b/src/Promotion/.gitattributes @@ -0,0 +1,6 @@ +* text=auto + +/.github export-ignore +/Tests export-ignore +.gitattributes export-ignore +phpunit.xml export-ignore diff --git a/src/Promotion/.github/workflows/close-pull-request.yml b/src/Promotion/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000..113f8919 --- /dev/null +++ b/src/Promotion/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Thank you for your pull request. However, you have submitted this PR on a Vanilo sub-module which is a read-only split of `vanilo/framework`. Please submit your PR on the https://github.com/vanilophp/framework repository.

Thanks!" diff --git a/src/Promotion/.github/workflows/tests.yml b/src/Promotion/.github/workflows/tests.yml new file mode 100644 index 00000000..c4c6fd52 --- /dev/null +++ b/src/Promotion/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: tests + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + php: ['8.2', '8.3'] + laravel: ['10.43', '10.48', '11.0', '11.14'] + name: PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} + steps: + - name: Checkout + uses: actions/checkout@master + - name: Installing PHP + uses: shivammathur/setup-php@master + with: + php-version: ${{ matrix.php }} + extensions: mbstring, json, sqlite3 + tools: composer:v2 + - name: Lock Laravel Version + run: composer require "illuminate/support:${{ matrix.laravel }}.*" --no-update -v && composer require "illuminate/console:${{ matrix.laravel }}.*" --no-update -v + - name: Composer Install + run: composer install --prefer-dist --no-progress --no-interaction + - name: Run Tests + run: php vendor/bin/phpunit --testdox diff --git a/src/Promotion/Actions/CartFixedDiscount.php b/src/Promotion/Actions/CartFixedDiscount.php new file mode 100644 index 00000000..d01e337f --- /dev/null +++ b/src/Promotion/Actions/CartFixedDiscount.php @@ -0,0 +1,36 @@ + Expect::float(0)->required()])->castTo('array'); + } + + public function getSchemaSample(array $mergeWith = null): array + { + return ['amount' => 19.99]; + } +} diff --git a/src/Promotion/Contracts/Coupon.php b/src/Promotion/Contracts/Coupon.php new file mode 100644 index 00000000..40971da0 --- /dev/null +++ b/src/Promotion/Contracts/Coupon.php @@ -0,0 +1,28 @@ + Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/src/Promotion/Models/Coupon.php b/src/Promotion/Models/Coupon.php new file mode 100644 index 00000000..43bc39d7 --- /dev/null +++ b/src/Promotion/Models/Coupon.php @@ -0,0 +1,65 @@ + 'datetime', + ]; + + public function promotion(): BelongsTo + { + return $this->belongsTo(PromotionProxy::modelClass()); + } + + public static function findByCode(string $code): ?CouponInterface + { + return static::where('code', $code)->first(); + } + + public function getPromotion(): Promotion + { + return $this->promotion; + } + + public function canBeUsed(): bool + { + return !$this->isDepleted() && !$this->isExpired(); + } + + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + public function isDepleted(): bool + { + if (!$this->usage_limit) { + return false; + } + + return $this->usage_count >= $this->usage_limit; + } +} diff --git a/src/Promotion/Models/CouponProxy.php b/src/Promotion/Models/CouponProxy.php new file mode 100644 index 00000000..9bd22dd4 --- /dev/null +++ b/src/Promotion/Models/CouponProxy.php @@ -0,0 +1,21 @@ + 'datetime', + 'ends_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'priority' => 'int', + 'usage_limit' => 'int', + 'usage_count' => 'int', + 'is_exclusive' => 'bool', + 'is_coupon_based' => 'bool', + 'applies_to_discounted' => 'bool', + ]; + + public function coupons(): HasMany + { + return $this->hasMany(CouponProxy::modelClass()); + } + + public function rules(): HasMany + { + return $this->hasMany(PromotionRuleProxy::modelClass()); + } + + public function isValid(?\DateTimeInterface $at = null): bool + { + if ($this->usage_count >= $this->usage_limit) { + return false; + } + + if (!$this->ends_at) { + return true; + } + + if ($at) { + return $this->ends_at->isAfter($at); + } + + return $this->ends_at->isFuture(); + } + + public function addRule(PromotionRuleType|string $type, array $configuration): self + { + $typeId = match (true) { + $type instanceof PromotionRuleType => PromotionRuleTypes::getIdOf($type::class), // $type is an object + null !== PromotionRuleTypes::getClassOf($type) => $type, // $type is the registered type ID + default => PromotionRuleTypes::getIdOf($type), // $type is the class name of the rule type + }; + + $this->rules()->create([ + 'type' => $typeId, + 'configuration' => $configuration, + ]); + + return $this; + } +} diff --git a/src/Promotion/Models/PromotionAction.php b/src/Promotion/Models/PromotionAction.php new file mode 100644 index 00000000..0a167a65 --- /dev/null +++ b/src/Promotion/Models/PromotionAction.php @@ -0,0 +1,49 @@ + 'array', + ]; + + public function promotion(): BelongsTo + { + return $this->belongsTo(PromotionProxy::modelClass()); + } + + public function getActionType(): PromotionActionType + { + // TODO: Implement getActionType() method. + } + + public function execute(object $subject): Adjustable + { + // TODO: Implement executeActionType() method. + } +} diff --git a/src/Promotion/Models/PromotionActionProxy.php b/src/Promotion/Models/PromotionActionProxy.php new file mode 100644 index 00000000..63df5afa --- /dev/null +++ b/src/Promotion/Models/PromotionActionProxy.php @@ -0,0 +1,11 @@ + 'array', + ]; + + public function promotion(): BelongsTo + { + return $this->belongsTo(PromotionProxy::modelClass()); + } + + public function getRuleType(): PromotionRuleType + { + return PromotionRuleTypes::make($this->type); + } + + public function isPassing(object $subject): bool + { + return $this->getRuleType()->isPassing($subject, $this->configuration()); + } + + public function getConfigurationSchema(): ?Schematized + { + return SchemaDefinition::wrap($this->getRuleType()); + } +} diff --git a/src/Promotion/Models/PromotionRuleProxy.php b/src/Promotion/Models/PromotionRuleProxy.php new file mode 100644 index 00000000..e4a25c74 --- /dev/null +++ b/src/Promotion/Models/PromotionRuleProxy.php @@ -0,0 +1,11 @@ +make($class, $parameters); + } +} diff --git a/src/Promotion/PromotionRuleTypes.php b/src/Promotion/PromotionRuleTypes.php new file mode 100644 index 00000000..f3883d99 --- /dev/null +++ b/src/Promotion/PromotionRuleTypes.php @@ -0,0 +1,37 @@ +make($class, $parameters); + } +} diff --git a/src/Promotion/Providers/ModuleServiceProvider.php b/src/Promotion/Providers/ModuleServiceProvider.php new file mode 100644 index 00000000..455807ba --- /dev/null +++ b/src/Promotion/Providers/ModuleServiceProvider.php @@ -0,0 +1,30 @@ + Expect::int(0)->required()])->castTo('array'); + } + + public function getSchemaSample(array $mergeWith = null): array + { + return ['count' => 2]; + } + + public function isPassing(object $subject, array $configuration): bool + { + $count = match (true) { + method_exists($subject, 'itemCount') => $subject->itemCount(), + method_exists($subject, 'getItems') => count($subject->getItems()), + default => throw new \InvalidArgumentException('The cart quantity promotion rule requires either `itemCount()` or `getItems()` method on its subject'), + }; + + $configuration = (new Processor())->process($this->getSchema(), $configuration); + + return $count >= $configuration['count']; + } +} diff --git a/src/Promotion/Tests/AAASmokeTest.php b/src/Promotion/Tests/AAASmokeTest.php new file mode 100644 index 00000000..5a467b8d --- /dev/null +++ b/src/Promotion/Tests/AAASmokeTest.php @@ -0,0 +1,31 @@ +assertTrue(true); + } + + /** + * Test for minimum PHP version + * + * @depends smoke + * @test + */ + public function php_version_satisfies_requirements() + { + $this->assertFalse( + version_compare(PHP_VERSION, self::MIN_PHP_VERSION, '<'), + 'PHP version ' . self::MIN_PHP_VERSION . ' or greater is required but only ' + . PHP_VERSION . ' found.' + ); + } +} diff --git a/src/Promotion/Tests/CouponTest.php b/src/Promotion/Tests/CouponTest.php new file mode 100644 index 00000000..cbff7da2 --- /dev/null +++ b/src/Promotion/Tests/CouponTest.php @@ -0,0 +1,154 @@ +endOfDay(); + $coupon = Coupon::create([ + 'promotion_id' => PromotionFactory::new()->create()->id, + 'code' => 'coupon', + 'per_customer_usage_limit' => 2, + 'usage_limit' => 4, + 'usage_count' => 4, + 'expires_at' => $expiryDate, + ]); + + $coupon = $coupon->refresh(); + + $this->assertEquals('coupon', $coupon->code); + $this->assertEquals(2, $coupon->per_customer_usage_limit); + $this->assertEquals(4, $coupon->usage_limit); + $this->assertEquals(4, $coupon->usage_count); + $this->assertEquals($expiryDate->toDateTimeString(), $coupon->expires_at->toDateTimeString()); + } + + /** @test */ + public function all_mutable_fields_can_be_set() + { + $coupon = new Coupon(); + + $coupon->code = 'coupon'; + $coupon->per_customer_usage_limit = 1; + $coupon->usage_limit = 15; + $coupon->usage_count = 4; + $coupon->expires_at = Carbon::now()->endOfDay()->toDateTimeString(); + + $this->assertEquals('coupon', $coupon->code); + $this->assertEquals(1, $coupon->per_customer_usage_limit); + $this->assertEquals(15, $coupon->usage_limit); + $this->assertEquals(4, $coupon->usage_count); + $this->assertEquals(Carbon::now()->endOfDay()->toDateTimeString(), $coupon->expires_at); + } + + /** @test */ + public function code_must_be_unique() + { + $this->expectExceptionMessageMatches('/UNIQUE constraint failed/'); + + $c1 = Coupon::create([ + 'code' => 'coupon-1', + 'promotion_id' => PromotionFactory::new()->create()->id, + ]); + + $c2 = Coupon::create([ + 'code' => 'coupon-1', + 'promotion_id' => PromotionFactory::new()->create()->id, + ]); + } + + /** @test */ + public function the_fields_are_of_proper_types() + { + $coupon = Coupon::create([ + 'promotion_id' => PromotionFactory::new()->create()->id, + 'code' => 'coupon', + 'per_customer_usage_limit' => 2, + 'usage_limit' => 8, + 'usage_count' => 7, + 'expires_at' => Carbon::parse('tomorrow'), + ]); + + $coupon = $coupon->refresh(); + + $this->assertIsInt($coupon->per_customer_usage_limit); + $this->assertIsInt($coupon->usage_limit); + $this->assertIsInt($coupon->usage_count); + $this->assertInstanceOf(Carbon::class, $coupon->expires_at); + $this->assertInstanceOf(Carbon::class, $coupon->created_at); + $this->assertInstanceOf(Carbon::class, $coupon->updated_at); + } + + /** @test */ + public function can_return_coupon_by_code() + { + CouponFactory::new(['code' => 'test-code'])->create(); + + $this->assertEquals('test-code', Coupon::findByCode('test-code')->code); + } + + /** @test */ + public function can_return_promotion() + { + $promotion = PromotionFactory::new(['name' => 'Test promo'])->create(); + $coupon = CouponFactory::new(['promotion_id' => $promotion->id])->create(); + + $this->assertEquals('Test promo', $coupon->getPromotion()->name); + } + + /** @test */ + public function determines_if_its_depleted() + { + $depleted = CouponFactory::new(['usage_limit' => 3, 'usage_count' => 3])->create(); + $notDepleted = CouponFactory::new(['usage_limit' => 3, 'usage_count' => 2])->create(); + + $this->assertTrue($depleted->isDepleted()); + $this->assertFalse($notDepleted->isDepleted()); + } + + /** @test */ + public function determines_if_its_expired() + { + $expiredCoupon = CouponFactory::new(['expires_at' => Carbon::now()->subWeek()])->create(); + $notExpired = CouponFactory::new(['expires_at' => Carbon::now()->addWeek()])->create(); + + $this->assertTrue($expiredCoupon->isExpired()); + $this->assertFalse($notExpired->isExpired()); + } + + /** @test */ + public function determines_if_can_be_used() + { + $canBeUsed = CouponFactory::new([ + 'expires_at' => Carbon::now()->addWeek(), + 'usage_limit' => 3, + 'usage_count' => 2, + ])->create(); + + $cantBeUsedA = CouponFactory::new([ + 'expires_at' => Carbon::now()->subWeek(), + 'usage_limit' => 3, + 'usage_count' => 2, + ])->create(); + + $cantBeUsedB = CouponFactory::new([ + 'expires_at' => Carbon::now()->addweek(), + 'usage_limit' => 3, + 'usage_count' => 3, + ])->create(); + + $this->assertTrue($canBeUsed->canBeUsed()); + $this->assertFalse($cantBeUsedA->canBeUsed()); + $this->assertFalse($cantBeUsedB->canBeUsed()); + } +} diff --git a/src/Promotion/Tests/Examples/CartTotalRule.php b/src/Promotion/Tests/Examples/CartTotalRule.php new file mode 100644 index 00000000..9eff510b --- /dev/null +++ b/src/Promotion/Tests/Examples/CartTotalRule.php @@ -0,0 +1,31 @@ +itemCount; + } + + public function getUser(): ?Authenticatable + { + // TODO: Implement getUser() method. + } + + public function setUser(int|Authenticatable|string|null $user): void + { + // TODO: Implement setUser() method. + } + + public function getItems(): Collection + { + // TODO: Implement getItems() method. + } + + public function itemsTotal(): float + { + // TODO: Implement itemsTotal() method. + } + + public function total(): float + { + // TODO: Implement total() method. + } +} diff --git a/src/Promotion/Tests/Examples/NthOrderRule.php b/src/Promotion/Tests/Examples/NthOrderRule.php new file mode 100644 index 00000000..7cdbb0ef --- /dev/null +++ b/src/Promotion/Tests/Examples/NthOrderRule.php @@ -0,0 +1,31 @@ + $this->faker->text(15), + 'promotion_id' => PromotionFactory::new()->create()->id, + ]; + } +} diff --git a/src/Promotion/Tests/Factories/PromotionFactory.php b/src/Promotion/Tests/Factories/PromotionFactory.php new file mode 100644 index 00000000..6777ce5c --- /dev/null +++ b/src/Promotion/Tests/Factories/PromotionFactory.php @@ -0,0 +1,40 @@ + $this->faker->words(mt_rand(2, 5), true), + ]; + } + + public function expired(): self + { + return $this->state(function (array $attributes) { + return [ + 'ends_at' => Carbon::now()->subDays(2), + ]; + }); + } + + public function inActivePeriod(): self + { + return $this->state(function (array $attributes) { + return [ + 'starts_at' => Carbon::now()->subDays(3), + 'ends_at' => Carbon::now()->addDays(3), + ]; + }); + } +} diff --git a/src/Promotion/Tests/PromotionRuleTest.php b/src/Promotion/Tests/PromotionRuleTest.php new file mode 100644 index 00000000..09affe33 --- /dev/null +++ b/src/Promotion/Tests/PromotionRuleTest.php @@ -0,0 +1,79 @@ + 'awesome', 'promotion_id' => PromotionFactory::new()->create()->id] + ); + + $this->assertInstanceOf(PromotionRule::class, $rule); + $this->assertInstanceOf(Promotion::class, $rule->promotion); + $this->assertEquals('awesome', $rule->type); + } + + /** @test */ + public function it_can_store_and_retrieve_configuration() + { + $rule = PromotionRule::create( + [ + 'type' => 'awesome', + 'promotion_id' => PromotionFactory::new()->create()->id, + 'configuration' => ['count' => 10], + ] + ); + + $this->assertEquals(['count' => 10], $rule->configuration()); + } + + /** @test */ + public function it_can_run_the_type_passing() + { + $ruleA = PromotionRule::create( + [ + 'type' => CartQuantity::ID, + 'promotion_id' => PromotionFactory::new()->create()->id, + 'configuration' => ['count' => 10], + ] + ); + + $ruleB = PromotionRule::create( + [ + 'type' => CartQuantity::ID, + 'promotion_id' => PromotionFactory::new()->create()->id, + 'configuration' => ['count' => 3], + ] + ); + + $this->assertEquals(['count' => 10], $ruleA->configuration()); + $this->assertFalse($ruleA->isPassing(new DummyCart())); + + $this->assertEquals(['count' => 3], $ruleB->configuration()); + $this->assertTrue($ruleB->isPassing(new DummyCart())); + } + + /** @test */ + public function throws_exception_if_configuration_needed_but_its_not_there() + { + $this->expectException(ValidationException::class); + + $rule = PromotionRule::create( + ['type' => CartQuantity::ID, 'promotion_id' => PromotionFactory::new()->create()->id] + ); + + $rule->isPassing(new DummyCart()); + } +} diff --git a/src/Promotion/Tests/PromotionRuleTypesTest.php b/src/Promotion/Tests/PromotionRuleTypesTest.php new file mode 100644 index 00000000..7b45eba4 --- /dev/null +++ b/src/Promotion/Tests/PromotionRuleTypesTest.php @@ -0,0 +1,43 @@ +assertCount($originalCount + 2, PromotionRuleTypes::choices()); + } + + /** @test */ + public function registered_gateway_instances_can_be_returned() + { + PromotionRuleTypes::register('nt_order', NthOrderRule::class); + + $this->assertInstanceOf(NthOrderRule::class, PromotionRuleTypes::make('nt_order')); + } + + /** @test */ + public function attempting_to_retrieve_an_unregistered_gateway_returns_null() + { + $this->assertNull(PromotionRuleTypes::getClassOf('randomness')); + } + + /** @test */ + public function registering_a_gateway_without_implementing_the_interface_is_not_allowed() + { + $this->expectException(\InvalidArgumentException::class); + PromotionRuleTypes::register('whatever', \stdClass::class); + } +} diff --git a/src/Promotion/Tests/PromotionTest.php b/src/Promotion/Tests/PromotionTest.php new file mode 100644 index 00000000..a2c8eaea --- /dev/null +++ b/src/Promotion/Tests/PromotionTest.php @@ -0,0 +1,155 @@ + 'Sample Promotion']); + $this->assertInstanceOf(Promotion::class, $promotion); + $this->assertEquals('Sample Promotion', $promotion->name); + } + + /** @test */ + public function all_mutable_fields_can_be_mass_assigned() + { + $now = Carbon::now()->startOfDay()->toDateTimeString(); + $nextMonth = Carbon::now()->addMonths(1)->endOfDay()->toDateTimeString(); + + $promotion = Promotion::create([ + 'name' => 'Awesome promotion', + 'description' => 'The description', + 'priority' => 4, + 'is_exclusive' => false, + 'usage_limit' => 15, + 'usage_count' => 2, + 'is_coupon_based' => false, + 'starts_at' => $now, + 'ends_at' => $nextMonth, + 'applies_to_discounted' => false, + ]); + + $this->assertEquals('Awesome promotion', $promotion->name); + $this->assertEquals('The description', $promotion->description); + $this->assertEquals(4, $promotion->priority); + $this->assertFalse($promotion->is_exclusive); + $this->assertEquals(15, $promotion->usage_limit); + $this->assertEquals(2, $promotion->usage_count); + $this->assertFalse($promotion->is_coupon_based); + $this->assertEquals($now, $promotion->starts_at); + $this->assertEquals($nextMonth, $promotion->ends_at); + $this->assertFalse($promotion->applies_to_discounted); + } + + /** @test */ + public function all_mutable_fields_can_be_set() + { + $now = Carbon::now()->startOfDay()->startOfDay()->toDateTimeString(); + $nextMonth = Carbon::now()->addMonth()->endOfDay()->toDateTimeString(); + + $promotion = new Promotion(); + + $promotion->name = 'Awesome promotion'; + $promotion->description = 'The description'; + $promotion->priority = 4; + $promotion->is_exclusive = false; + $promotion->usage_limit = 15; + $promotion->usage_count = 1; + $promotion->is_coupon_based = false; + $promotion->applies_to_discounted = false; + $promotion->starts_at = $now; + $promotion->ends_at = $nextMonth; + + $this->assertEquals('Awesome promotion', $promotion->name); + $this->assertEquals('The description', $promotion->description); + $this->assertEquals(4, $promotion->priority); + $this->assertFalse($promotion->is_exclusive); + $this->assertEquals(15, $promotion->usage_limit); + $this->assertEquals(1, $promotion->usage_count); + $this->assertFalse($promotion->is_coupon_based); + $this->assertEquals($now, $promotion->starts_at->toDateTimeString()); + $this->assertEquals($nextMonth, $promotion->ends_at->toDateTimeString()); + $this->assertFalse($promotion->applies_to_discounted); + } + + /** @test */ + public function the_fields_are_of_proper_types() + { + $promotion = Promotion::create([ + 'name' => 'Typed Promotion', + 'priority' => 4, + 'usage_limit' => 100, + 'usage_count' => 35, + 'starts_at' => Carbon::now(), + 'ends_at' => Carbon::parse('next month'), + ]); + + $promotion = Promotion::find($promotion->id); + + $this->assertIsInt($promotion->priority); + $this->assertIsInt($promotion->usage_limit); + $this->assertIsInt($promotion->usage_count); + $this->assertIsBool($promotion->is_exclusive); + $this->assertIsBool($promotion->is_coupon_based); + $this->assertIsBool($promotion->applies_to_discounted); + $this->assertInstanceOf(Carbon::class, $promotion->starts_at); + $this->assertInstanceOf(Carbon::class, $promotion->ends_at); + $this->assertInstanceOf(Carbon::class, $promotion->created_at); + $this->assertInstanceOf(Carbon::class, $promotion->updated_at); + } + + /** @test */ + public function can_determine_if_its_valid() + { + $validPromotionA = PromotionFactory::new([ + 'ends_at' => Carbon::now()->addMonth(), + 'usage_limit' => 100, + 'usage_count' => 35, + ])->create(); + + $validPromotionB = PromotionFactory::new([ + 'usage_limit' => 100, + 'usage_count' => 35, + ])->create(); + + $invalidPromotionA = PromotionFactory::new([ + 'ends_at' => Carbon::now()->addMonth(), + 'usage_limit' => 100, + 'usage_count' => 101, + ])->create(); + + $invalidPromotionB = PromotionFactory::new([ + 'ends_at' => Carbon::now()->subMonths(), + 'usage_limit' => 100, + 'usage_count' => 5, + ])->create(); + + $this->assertTrue($validPromotionA->isValid()); + $this->assertTrue($validPromotionB->isValid()); + $this->assertFalse($invalidPromotionA->isValid()); + $this->assertFalse($invalidPromotionB->isValid()); + $this->assertFalse($validPromotionA->isValid(Carbon::now()->addYear())); + $this->assertTrue($validPromotionA->isValid(Carbon::now()->addWeek())); + } + + /** @test */ + public function it_can_add_rule_and_validate() + { + $promotion = PromotionFactory::new()->create(); + $promotion->addRule(PromotionRuleTypes::make(CartQuantity::ID), ['count' => 3]); + + $this->assertEquals(1, $promotion->rules()->count()); + $this->assertEquals(['count' => 3], $promotion->rules()->first()->configuration); + $this->assertEquals(CartQuantity::ID, $promotion->rules()->first()->type); + } +} diff --git a/src/Promotion/Tests/Rules/CartQuantityTest.php b/src/Promotion/Tests/Rules/CartQuantityTest.php new file mode 100644 index 00000000..619e56b5 --- /dev/null +++ b/src/Promotion/Tests/Rules/CartQuantityTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(CartQuantity::class, $ruleType); + } + + /** @test */ + public function throws_exception_if_configuration_is_wrong() + { + $this->expectException(ValidationException::class); + $cartQuantityRule = PromotionRuleTypes::make(CartQuantity::ID); + + $this->assertFalse($cartQuantityRule->isPassing(new DummyCart(), ['wrong' => 'config'])); + } + + /** @test */ + public function passes_if_rule_is_valid() + { + $cartQuantityRuleType = PromotionRuleTypes::make(CartQuantity::ID); + + $this->assertTrue($cartQuantityRuleType->isPassing(new DummyCart(4), ['count' => 3])); + $this->assertFalse($cartQuantityRuleType->isPassing(new DummyCart(6), ['count' => 7])); + } +} diff --git a/src/Promotion/Tests/TestCase.php b/src/Promotion/Tests/TestCase.php new file mode 100644 index 00000000..23b3d5f5 --- /dev/null +++ b/src/Promotion/Tests/TestCase.php @@ -0,0 +1,51 @@ +setUpDatabase($this->app); + } + + protected function getPackageProviders($app) + { + return [ + ConcordServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app) + { + $app['config']->set('database.default', 'sqlite'); + $app['config']->set('database.connections.sqlite', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + protected function setUpDatabase($app) + { + Artisan::call('migrate', ['--force' => true]); + } + + protected function resolveApplicationConfiguration($app) + { + parent::resolveApplicationConfiguration($app); + + $app['config']->set('concord.modules', [ + PromotionModule::class, + ]); + } +} diff --git a/src/Promotion/composer.json b/src/Promotion/composer.json new file mode 100644 index 00000000..3ae65375 --- /dev/null +++ b/src/Promotion/composer.json @@ -0,0 +1,43 @@ +{ + "name": "vanilo/promotion", + "description": "Vanilo Promotion Module", + "type": "library", + "license": "MIT", + "minimum-stability": "dev", + "prefer-stable": true, + "keywords": ["promotion", "ecommerce", "vanilo", "laravel"], + "support": { + "issues": "https://github.com/vanilophp/framework/issues", + "source": "https://github.com/vanilophp/promotion" + }, + "authors": [ + { + "name": "Hunor Kedves", + "homepage": "https://github.com/kedves" + }, + { + "name": "Attila Fulop", + "homepage": "https://github.com/fulopattila122" + } + ], + "require": { + "php": "^8.2", + "konekt/concord": "^1.13", + "konekt/xtend": "^1.2", + "laravel/framework": "^10.43|^11.0", + "vanilo/support": "^4.0", + "vanilo/adjustments": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "orchestra/testbench": "^8.0|^9.0" + }, + "autoload": { + "psr-4": { "Vanilo\\Promotion\\": "" } + }, + "extra": { + "branch-alias": { + "dev-master": "4.2-dev" + } + } +} diff --git a/src/Promotion/phpunit.xml b/src/Promotion/phpunit.xml new file mode 100644 index 00000000..bfd33d1a --- /dev/null +++ b/src/Promotion/phpunit.xml @@ -0,0 +1,12 @@ + + + + + + ./Tests + + + diff --git a/src/Promotion/resources/database/migrations/2024_06_08_131453_create_promotions_table.php b/src/Promotion/resources/database/migrations/2024_06_08_131453_create_promotions_table.php new file mode 100644 index 00000000..b26b5929 --- /dev/null +++ b/src/Promotion/resources/database/migrations/2024_06_08_131453_create_promotions_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->integer('priority')->unsigned()->default(0); + $table->boolean('is_exclusive')->default(false); + $table->unsignedInteger('usage_limit')->nullable(); + $table->unsignedInteger('usage_count')->default(0); + $table->boolean('is_coupon_based')->default(false); + $table->dateTime('starts_at')->nullable(); + $table->dateTime('ends_at')->nullable(); + $table->boolean('applies_to_discounted')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::drop('promotions'); + } +}; diff --git a/src/Promotion/resources/database/migrations/2024_06_08_164653_create_coupons_table.php b/src/Promotion/resources/database/migrations/2024_06_08_164653_create_coupons_table.php new file mode 100644 index 00000000..566a478d --- /dev/null +++ b/src/Promotion/resources/database/migrations/2024_06_08_164653_create_coupons_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedInteger('promotion_id'); + $table->foreign('promotion_id')->references('id')->on('promotions'); + + $table->string('code')->unique(); + $table->unsignedInteger('usage_limit')->nullable(); + $table->unsignedInteger('per_customer_usage_limit')->nullable(); + $table->unsignedInteger('usage_count')->default(0); + $table->dateTime('expires_at')->nullable(); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::drop('coupons'); + } +}; diff --git a/src/Promotion/resources/database/migrations/2024_06_09_095853_create_promotion_rules_table.php b/src/Promotion/resources/database/migrations/2024_06_09_095853_create_promotion_rules_table.php new file mode 100644 index 00000000..5191d4dd --- /dev/null +++ b/src/Promotion/resources/database/migrations/2024_06_09_095853_create_promotion_rules_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedInteger('promotion_id'); + $table->string('type'); + $table->json('configuration')->nullable(); + $table->timestamps(); + $table->foreign('promotion_id')->references('id')->on('promotions'); + }); + } + + public function down(): void + { + Schema::drop('promotion_rules'); + } +}; diff --git a/src/Promotion/resources/database/migrations/2024_06_10_175853_create_promotion_actions_table.php b/src/Promotion/resources/database/migrations/2024_06_10_175853_create_promotion_actions_table.php new file mode 100644 index 00000000..278af36b --- /dev/null +++ b/src/Promotion/resources/database/migrations/2024_06_10_175853_create_promotion_actions_table.php @@ -0,0 +1,26 @@ +id(); + $table->unsignedInteger('promotion_id'); + $table->string('type'); + $table->json('configuration')->nullable(); + $table->timestamps(); + $table->foreign('promotion_id')->references('id')->on('promotions'); + }); + } + + public function down(): void + { + Schema::drop('promotion_actions'); + } +}; diff --git a/src/Promotion/resources/manifest.php b/src/Promotion/resources/manifest.php new file mode 100644 index 00000000..12fecca7 --- /dev/null +++ b/src/Promotion/resources/manifest.php @@ -0,0 +1,8 @@ + 'Vanilo Promotion Module', + 'version' => '4.2-dev' +];