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'
+];