Compare commits

..
Author SHA1 Message Date
dependabot[bot] 71722993c6 chore(deps): bump the actions group across 1 directory with 2 updates
Bumps the actions group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `actions/checkout` from 6.0.3 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...3d3c42e5aac5ba805825da76410c181273ba90b1)

Updates `shivammathur/setup-php` from 2.37.2 to 2.40.0
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](https://github.com/shivammathur/setup-php/compare/f3e473d116dcccaddc5834248c87452386958240...eb7c497e18156a6bbabfef1d3a82760b9eda3962)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: shivammathur/setup-php
  dependency-version: 2.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 21:44:57 +00:00
github-actions[bot] 8f99b148d6 Merge pull request #17 from wearepixel/dependabot/composer/php-minor-patch-3dfb8bd77a
chore(deps-dev): bump laravel/pint from 1.29.1 to 1.32.1 in the php-minor-patch group
2026-11-03 21:45:14 +00:00
github-actions[bot] 6bd569cca7 Merge pull request #18 from wearepixel/dependabot/composer/phpstan/phpstan-2.3.4
chore(deps-dev): bump phpstan/phpstan from 2.2.x-dev to 2.3.4
2026-11-03 21:45:07 +00:00
dependabot[bot] 63d310bf7e chore(deps-dev): bump phpstan/phpstan from 2.2.x-dev to 2.3.4
Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.x-dev to 2.3.4.
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.3.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-03 21:44:19 +00:00
dependabot[bot] c9163a7d78 chore(deps-dev): bump laravel/pint
Bumps the php-minor-patch group with 1 update: [laravel/pint](https://github.com/laravel/pint).


Updates `laravel/pint` from 1.29.1 to 1.32.1
- [Release notes](https://github.com/laravel/pint/releases)
- [Changelog](https://github.com/laravel/pint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/laravel/pint/compare/v1.29.1...v1.32.1)

---
updated-dependencies:
- dependency-name: laravel/pint
  dependency-version: 1.32.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: php-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-03 21:43:59 +00:00
Joel Male 45ef72894b fix: correct custom driver registration hint in cart:make:driver
The command told users to register their driver in config/cart.php
under "drivers", but CartManager has no such registration point for
custom drivers. The actual mechanism is Cart::extend() called from a
service provider's boot() method.
2026-08-04 12:06:58 +10:00
Joel Male c6ec19121c docs: link README to hosted docs and correct supported Laravel versions
Adds a Spatie-style inline link to the extensive documentation and
points the Documentation section at the versioned v3 introduction
page. Also corrects the supported Laravel range (11-13, not 10-13)
to match the illuminate/* constraints in composer.json.
2026-08-04 12:06:57 +10:00
Joel Male c8baf7f0a8 docs: point to the new hosted documentation site 2026-08-02 15:42:55 +10:00
pixel-workflows[bot] 0ea24eb419 chore: sync files from wearepixel/pixel-workflows - synced local 'pint.json' with remote 'files/pint.json' 2026-07-27 08:21:51 +00:00
pixel-workflows[bot] 917d530006 chore: sync files from wearepixel/pixel-workflows - created local '.gitattributes' from remote 'files/.gitattributes' 2026-07-27 08:21:48 +00:00
pixel-workflows[bot] d56ce61dec chore: sync files from wearepixel/pixel-workflows - created local '.editorconfig' from remote 'files/.editorconfig' 2026-07-27 08:21:45 +00:00
pixel-workflows[bot] 11d734e848 chore: sync files from wearepixel/pixel-workflows - created local '.github/workflows/dependabot-slack-alert.yml' from remote 'files/.github/workflows/dependabot-slack-alert.yml' 2026-07-27 08:21:42 +00:00
pixel-workflows[bot] 07d155159d chore: sync files from wearepixel/pixel-workflows - synced local '.github/workflows/dependabot-auto-merge.yml' with remote 'files/.github/workflows/dependabot-auto-merge.yml' 2026-07-27 08:21:39 +00:00
pixel-workflows[bot] 45362d3c62 chore: update dependabot configuration 2026-07-27 08:20:56 +00:00
Joel Male d1169f502f Merge pull request #11 from wearepixel/feature/v3
feat: v3 - storage drivers, first-class objects, testing utilities, Livewire/Inertia support, Artisan generators
2026-06-13 15:57:26 +10:00
Joel Male bfdea648ae chore: merge master, resolve conflicts keeping v3 2026-06-13 14:33:14 +10:00
Joel Male 01189993f2 feat: add applies_to support for quantity-limited item conditions 2026-06-13 14:27:42 +10:00
dependabot[bot] 3375518354 chore(deps): bump shivammathur/setup-php in the actions group
Bumps the actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.1 to 2.37.2
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](https://github.com/shivammathur/setup-php/compare/7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc...f3e473d116dcccaddc5834248c87452386958240)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 14:21:41 +10:00
dependabot[bot] c493c73497 chore(deps): bump actions/checkout in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 14:21:41 +10:00
Joel Male 7427ce953a chore: bump version to 2.1.4 2026-06-13 14:19:39 +10:00
Joel Male 6ba50a1c48 fix: prevent save() on array after clear() with database driver 2026-06-13 14:18:55 +10:00
Joel Male c50fc70688 fix: restore @mixin and @method PHPDoc on CartFacade for IDE and PHPStan support 2026-06-13 14:17:21 +10:00
Joel Male c9ffd8b8a4 chore: update to v3.0.0 2026-06-13 14:16:28 +10:00
Joel Male f3c4ddae6e docs: add v3 recommended patterns to upgrade guide and link from README 2026-06-13 14:11:20 +10:00
Joel Male 3f1f5fb4d2 fix: add missing return type to getAssociatedModel 2026-06-13 14:09:29 +10:00
github-actions[bot] fce7125c5f Merge pull request #14 from wearepixel/dependabot/github_actions/actions-5dacb66663
chore(deps): bump shivammathur/setup-php from 2.37.1 to 2.37.2 in the actions group
2026-06-08 21:45:38 +00:00
dependabot[bot] 834e211443 chore(deps): bump shivammathur/setup-php in the actions group
Bumps the actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.1 to 2.37.2
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](https://github.com/shivammathur/setup-php/compare/7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc...f3e473d116dcccaddc5834248c87452386958240)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 21:45:26 +00:00
Joel Male 2ced1038af feat: add PHPStan level 5 with zero errors
- Install phpstan/phpstan, add phpstan.neon at level 5 targeting PHP 8.3
- Add phpstan-bootstrap.php stubs for Laravel helper functions (app,
  config, config_path, app_path, session) unavailable in standalone packages
- Fix CartCondition::setOrder() - removed incorrect @return int (method is void)
- Fix CartCondition::getCalculatedValue() - removed incorrect @return mixed PHPDoc
- Fix Cart::condition() - remove redundant instanceof check the type system already guarantees
- Fix Cart::fireEvent() - remove array_values() on already-list literal array
- Fix ItemCollection - add @property annotations for magic __get properties
- Fix ItemCollection::getAssociatedModel() - correct @return bool to @return mixed
- Fix ItemCollection::getConditions() - remove dead is_array && !instanceof check
- Fix Validator - add @method stub so PHPStan resolves make() through __callStatic
- Add ignoreErrors for trait.unused (HasCart designed for external Livewire use)
- Add ignoreErrors for round() mode mismatch (PHPStan 2.x PHP 8.4 stubs bug)
- Add phpstan step to CI (PHP 8.3 only, matches phpVersion config)
2026-06-06 16:02:16 +10:00
github-actions[bot] 9b198edb7d Merge pull request #13 from wearepixel/dependabot/github_actions/actions-6a98abd9ac
chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 in the actions group
2026-06-03 10:48:17 +00:00
dependabot[bot] 49959faace chore(deps): bump actions/checkout in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 10:48:04 +00:00
Joel Male e61ffae5fe chore: bump version to 2.1.3 2026-05-29 14:27:02 +10:00
Joel Male 10ab30f4e8 fix: correct add() @method signature to accept array input style 2026-05-29 14:14:34 +10:00
Joel Male 88a118d81b feat: make SessionDriver constructor args optional with app() defaults
new SessionDriver() now works out of the box in a Laravel app, resolving
the session and session ID from the container. Explicit args still work
as before. Also ignores .claude/ directory from version control.
2026-05-28 09:25:41 +10:00
Joel Male 4f87bd645c docs: update README for v3 features
- Fix vendor:publish tag to cart-config
- Update TOC with Storage Drivers, Artisan Generators, First-class Objects, Testing, and Livewire / Inertia sections
- Replace Storage Options section with expanded Storage Drivers section covering session, database, Redis, multi-driver, and custom drivers
- Add Artisan Generators section documenting cart:install, cart:make:coupon, cart:make:tax, cart:make:shipping, cart:make:driver, and cart:debug
- Add First-class Objects section documenting Coupon, TaxRule, and ShippingRate base classes
- Add Testing section documenting Cart::fake() and assertion methods
- Add Livewire / Inertia section documenting HasCart trait and ShareCartWithInertia
2026-05-27 21:53:37 +10:00
Joel Male bab304c620 docs: add UPGRADE.md and CHANGELOG.md 2026-05-27 21:50:54 +10:00
Joel Male fd77347542 fix: validate class name input and check file write in Make commands
Add path traversal protection via PHP class name regex validation to all
four Make* commands, check file_put_contents() return value for write
failures, and remove the unnecessary \$defer = false property from
CartServiceProvider.
2026-05-27 21:49:08 +10:00
Joel Male ce51042edf feat: register Artisan commands in CartServiceProvider 2026-05-27 21:45:16 +10:00
Joel Male c2b2563cf1 feat: add cart:debug command 2026-05-27 21:44:47 +10:00
Joel Male 15d4397968 feat: add cart:make:driver command 2026-05-27 21:43:59 +10:00
Joel Male 76fba35643 feat: add cart:make:shipping command 2026-05-27 21:43:34 +10:00
Joel Male b8140166c9 feat: add cart:make:tax command 2026-05-27 21:43:14 +10:00
Joel Male fc16e6c877 feat: add cart:make:coupon command 2026-05-27 21:42:51 +10:00
Joel Male 731aaa070d feat: add cart:install command 2026-05-27 21:42:28 +10:00
Joel Male 0efd4378a8 feat: add HasCart trait and ShareCartWithInertia 2026-05-27 21:38:44 +10:00
Joel Male a485bfb6f0 refactor: use assertEqualsWithDelta for float comparisons, add @internal to fake()
Replace Assert::assertEquals with Assert::assertEqualsWithDelta (epsilon 0.0001)
in assertSubTotal, assertTotal, and assertTotalQuantity to avoid IEEE 754
floating-point equality pitfalls. Add @internal docblock to fake() to signal
it is test-only. Add regression test for consecutive withItems calls.
2026-05-27 21:35:49 +10:00
Joel Male 8d04d18b48 test: add withCoupon test and fix withItems ID collision 2026-05-27 21:33:35 +10:00
Joel Male 61863e0bd5 feat: add Cart::fake(), assertion methods, and CartFactory
Adds Cart::fake() which swaps the active driver to NullDriver and returns
a CartFactory fluent test builder. Adds assertion methods (assertContains,
assertCount, assertTotalQuantity, assertSubTotal, assertTotal,
assertConditionApplied, assertEmpty, assertNotEmpty) to Cart for use in
tests. CartFactory provides withItems(), withCondition(), and withCoupon()
as chainable helpers for seeding cart state.
2026-05-27 21:31:54 +10:00
Joel Male c3a734bd7f feat: add Cart::coupon(), Cart::tax(), Cart::shipping()
Proxy methods that accept first-class Coupon, TaxRule, and ShippingRate
objects and delegate to Cart::condition() via toCondition().
2026-05-27 21:27:23 +10:00
Joel Male 2513e07436 feat: add ShippingRate base class 2026-05-27 21:24:44 +10:00
Joel Male b5edbb8ec0 feat: add TaxRule base class 2026-05-27 21:24:09 +10:00
Joel Male 867daaf328 feat: add Coupon base class 2026-05-27 21:23:39 +10:00
Joel Male e41739c436 feat: add CartConditionable interface and first-class exceptions 2026-05-27 21:23:07 +10:00
Joel Male 4877911cdd fix: serialize conditions to arrays before storage; type \$events
- serializeConditions() now maps CartCondition objects to arrays via
  toArray() before passing to the driver, preventing json_encode()
  producing {} for object values in the DatabaseDriver
- Add Dispatcher type hint to \$events property and constructor parameter
- Add database round-trip test for cart-level conditions
- Fix CartConditionsTest to retrieve condition objects from the cart
  rather than relying on object-reference side-effects that no longer
  hold after proper serialization
2026-05-27 21:21:44 +10:00
Joel Male f3e5e958b7 fix: resolve Cart.php regressions after driver refactor
- Cast \$value to string in updateQuantityRelative() before passing to preg_match() and str_replace() to fix TypeError when integer quantities are passed
- Reconstruct ItemAttributeCollection and CartCondition objects from serialized arrays in getContent() so attribute property access (->size) and condition methods (.getName()) work correctly after deserialization
- Store CartCondition objects directly (not serialized to arrays) in saveConditions() to preserve object identity for parsedRawValue tracking via getCalculatedValue()
- Remove ->values() re-indexing from serializeItems() conditions serialization to preserve original condition array indices after partial removal
2026-05-27 21:16:07 +10:00
Joel Male adf4bca74f chore: remove CartSession (replaced by driver system) 2026-05-27 21:09:50 +10:00
Joel Male 0c27ac4d9a test: update existing tests to use CartDriver 2026-05-27 21:09:40 +10:00
Joel Male 9abf6a7a1b feat: update CartServiceProvider to use CartManager 2026-05-27 21:08:10 +10:00
Joel Male e0103aadaa refactor: update Cart to use CartDriver interface
Replace the v2 Cart implementation (which used CartSession directly) with
the v3 design that accepts a CartDriver contract via constructor injection.
Removes validateConfig(), replaces $this->session with $this->driver, adds
getDriver()/extend() helpers, serializes items/conditions through the driver,
and drops the unused setDecimals() method.
2026-05-27 21:06:22 +10:00
Joel Male 90ea8c44e4 fix: correct Redis TTL units in config (seconds not minutes) 2026-05-27 21:03:18 +10:00
Joel Male 3b8fa4c838 feat: update cart config with v3 driver options 2026-05-27 21:02:32 +10:00
Joel Male 2f2d268e0f feat: add CartManager 2026-05-27 21:02:23 +10:00
Joel Male 71213d51f5 fix: null-safe json_decode in RedisDriver; add getSessionModel test to MultiDriverTest 2026-05-27 21:01:34 +10:00
Joel Male 83aa9a1431 feat: add MultiDriver 2026-05-27 20:59:45 +10:00
Joel Male 1705c23205 feat: add RedisDriver 2026-05-27 20:59:15 +10:00
Joel Male 9fcd50f81f test: add setSessionKey test to DatabaseDriverTest 2026-05-27 20:58:24 +10:00
Joel Male 4702cf98ab feat: add DatabaseDriver
Implements CartDriver for Eloquent-backed persistence using firstOrNew
pattern. Includes 9 unit tests covering CRUD, flush, clearItems, and
model retrieval.
2026-05-27 20:55:01 +10:00
Joel Male 8927ef08a0 fix: guard null writes in SessionDriver::setSessionKey 2026-05-27 20:53:19 +10:00
Joel Male c9f48d7e88 feat: add SessionDriver 2026-05-27 20:50:47 +10:00
Joel Male 11e23a946e test: fix strict_types and test name in NullDriverTest 2026-05-27 20:49:36 +10:00
Joel Male b3c4c53e63 feat: add NullDriver 2026-05-27 20:47:14 +10:00
Joel Male c9f3d76366 feat: add CartDriver interface 2026-05-27 20:46:48 +10:00
72 changed files with 3712 additions and 1570 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+5
View File
@@ -8,6 +8,7 @@ updates:
open-pull-requests-limit: 10
groups:
actions:
applies-to: version-updates
patterns: ["*"]
commit-message:
prefix: "chore"
@@ -23,9 +24,13 @@ updates:
open-pull-requests-limit: 10
groups:
php-minor-patch:
applies-to: version-updates
update-types:
- "minor"
- "patch"
php-security:
applies-to: security-updates
patterns: ["*"]
commit-message:
prefix: "chore"
include: "scope"
+49 -22
View File
@@ -1,32 +1,59 @@
name: dependabot-auto-merge
on: pull_request_target
name: Dependabot Auto-merge
on:
workflow_run:
workflows: ["Tests"]
types: [completed]
permissions:
pull-requests: write
contents: write
pull-requests: write
jobs:
dependabot:
auto-merge:
runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }}
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.actor.login == 'dependabot[bot]'
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-merge Dependabot PRs for semver-minor updates
if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}}
run: gh pr merge --auto --merge "$PR_URL"
- name: Find PR and check update type
id: check
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
PR_DATA=$(gh pr list --repo "${{ github.repository }}" --head "$HEAD_BRANCH" --json number,title,url,author --jq '.[0]')
- name: Auto-merge Dependabot PRs for semver-patch updates
if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}}
run: gh pr merge --auto --merge "$PR_URL"
PR_URL=$(echo "$PR_DATA" | jq -r '.url')
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
if [ "$PR_AUTHOR" != "app/dependabot" ]; then
echo "is_safe=false" >> $GITHUB_OUTPUT
echo "Not a Dependabot PR, skipping."
exit 0
fi
# Parse major version numbers from title: "bump X from A[.B.C] to D[.E.F]"
# Works for both "from 4.0.0 to 4.1.0" and "from 4 to 6" (GitHub Actions)
FROM_MAJOR=$(echo "$PR_TITLE" | grep -oP 'from \K[0-9]+')
TO_MAJOR=$(echo "$PR_TITLE" | grep -oP 'to \K[0-9]+')
echo "PR: $PR_TITLE (major: $FROM_MAJOR -> $TO_MAJOR)"
if [ "$FROM_MAJOR" = "$TO_MAJOR" ]; then
echo "is_safe=true" >> $GITHUB_OUTPUT
else
echo "is_safe=false" >> $GITHUB_OUTPUT
echo "Major version bump, skipping auto-merge."
fi
- name: Merge minor and patch updates
if: steps.check.outputs.is_safe == 'true'
run: gh pr merge --merge "${{ steps.check.outputs.pr_url }}"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,98 @@
name: Dependabot Slack Alert
on:
pull_request_target:
types: [opened]
jobs:
notify:
name: Notify Slack of major update
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Check if major version bump
id: check
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
from_major=$(echo "$PR_TITLE" | grep -oP 'from \K[0-9]+' | head -1)
to_major=$(echo "$PR_TITLE" | grep -oP 'to \K[0-9]+' | head -1)
if [ -n "$from_major" ] && [ -n "$to_major" ] && [ "$from_major" != "$to_major" ]; then
echo "is_major=true" >> $GITHUB_OUTPUT
echo "Major bump detected: $from_major -> $to_major"
else
echo "is_major=false" >> $GITHUB_OUTPUT
echo "Minor/patch bump, skipping"
fi
- name: Send Slack alert
if: steps.check.outputs.is_major == 'true'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
if [ -z "$SLACK_WEBHOOK_URL" ]; then
echo "SLACK_WEBHOOK_URL not configured for this repo, skipping"
exit 0
fi
payload=$(jq -n \
--arg repo "$REPO" \
--arg title "$PR_TITLE" \
--arg url "$PR_URL" \
--arg pr "#$PR_NUMBER" \
'{
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: ":dependabot: *Major dependency update requires review*"
}
},
{
type: "section",
fields: [
{type: "mrkdwn", text: ("*Repository*\n`" + $repo + "`")},
{type: "mrkdwn", text: ("*Pull Request*\n<" + $url + "|" + $pr + ">") }
]
},
{
type: "section",
text: {
type: "mrkdwn",
text: $title
}
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: "Major version bumps are not auto-merged as they may contain breaking changes."
}
]
},
{
type: "actions",
elements: [
{
type: "button",
text: {type: "plain_text", text: "Review PR"},
url: $url,
style: "primary"
}
]
}
]
}')
curl -s -o /dev/null -w "%{http_code}" -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-type: application/json" \
--data "$payload" | grep -q "^200$" \
&& echo "Slack notification sent" \
|| echo "Slack notification failed"
+6 -2
View File
@@ -18,10 +18,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP
uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2
uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis, memcached
@@ -37,3 +37,7 @@ jobs:
- name: Execute tests
run: vendor/bin/pest
- name: Static analysis
run: vendor/bin/phpstan analyse --no-progress
if: matrix.php == '8.3'
+1
View File
@@ -5,3 +5,4 @@
READMETODO.md
build
docs/superpowers/
.claude/
+61
View File
@@ -0,0 +1,61 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [3.0.0] - TBD
### Added
- **Storage driver system** (`CartDriver` interface, `CartManager`, `SessionDriver`, `DatabaseDriver`, `RedisDriver`, `NullDriver`, `MultiDriver`)
- **`Cart::fake()`** - swaps the active driver for a `NullDriver` and returns a `CartFactory` for fluent test state seeding
- **Assertion methods** on `Cart`: `assertContains()`, `assertCount()`, `assertTotalQuantity()`, `assertSubTotal()`, `assertTotal()`, `assertConditionApplied()`, `assertEmpty()`, `assertNotEmpty()`
- **`CartFactory`** - fluent builder for seeding test cart state (`withItems()`, `withCondition()`, `withCoupon()`)
- **`Coupon` abstract class** - extend to create first-class coupon objects that convert to `CartCondition`
- **`TaxRule` abstract class** - extend to create first-class tax rules that convert to `CartCondition`
- **`ShippingRate` abstract class** - extend to create first-class shipping rates that convert to `CartCondition`
- **`Cart::coupon()`**, **`Cart::tax()`**, **`Cart::shipping()`** - accept first-class objects or raw `CartCondition`
- **`HasCart` trait** - Livewire-aware trait with reactive `$cartItems`, `$cartTotal`, `$cartSubTotal`, `$cartCount` and proxy methods
- **`ShareCartWithInertia`** - static class for sharing cart state via `HandleInertiaRequests::share()`
- **`cart:install`** Artisan command - publishes config and scaffolds `app/Cart/` directory structure
- **`cart:make:coupon`** Artisan command - generates a `Coupon` subclass stub
- **`cart:make:tax`** Artisan command - generates a `TaxRule` subclass stub
- **`cart:make:shipping`** Artisan command - generates a `ShippingRate` subclass stub
- **`cart:make:driver`** Artisan command - generates a `CartDriver` implementation stub
- **`cart:debug`** Artisan command - dumps current cart state (non-production only)
- Laravel 13 support
### Changed
- `Cart` constructor now accepts a `CartDriver` instance instead of raw session/database storage
- Config publish tag renamed from `config` to `cart-config`
### Removed
- `Wearepixel\Cart\CartSession` - replaced by the driver system
## [2.1.1] - 2026-05-27
### Fixed
- PHP 8.4 deprecation warnings from Mockery implicit-nullable parameters in test bootstrap
- Upgraded CI runners from deprecated `ubuntu-20.04` to `ubuntu-latest`
### Changed
- `pestphp/pest` upgraded from `^3.0` to `^4.7`
## [2.1.0] - 2024-01-01
### Added
- Laravel 12 support
## [2.0.0] - 2023-01-01
### Added
- Initial v2 release
+9 -818
View File
@@ -7,834 +7,25 @@
[![License](https://poser.pugx.org/wearepixel/laravel-cart/license.svg)](https://packagist.org/packages/wearepixel/laravel-cart)
[![Free](https://img.shields.io/badge/free_for_non_commercial_use-brightgreen)](#-license)
A Cart Implementation for Laravel.
A Cart Implementation for Laravel. To learn all about it, head over to the [extensive documentation](https://wearepixel.com.au/docs/laravel-cart/v3/introduction).
Supported Laravel Versions: 10, 11, 12, and 13.
Supported Laravel Versions: 11, 12, and 13.
For Laravel 9.0 and below, please use version [1.0](https://github.com/wearepixel/laravel-cart/releases/tag/1.0.12)
For Laravel 10.0 and below, please use version [1.0](https://github.com/wearepixel/laravel-cart/releases/tag/1.0.12)
## Table of Contents
> **Upgrading from v2?** See the [Upgrade Guide](https://wearepixel.com.au/docs/laravel-cart/v3/upgrade-guide) for breaking changes and recommended v3 patterns.
- [Getting Started](#-getting-started)
- [Demo](#-deni)
- [Documentation](#-documentation)
- [Configuration](#configuration)
- [Basic Usage](#basic-usage)
- [Conditions](#conditions)
- [Cart Items](#cart-items)
- [Database Support](#database-support)
- [Events](#events)
- [Credits](#-credits)
- [License](#-license)
## 🚀 Getting Started
### 🔥 Installing
## Installation
Install the package through [Composer](http://getcomposer.org/).
`composer require wearepixel/laravel-cart`
## 🧑‍🍳 Demo
```php
// Add an item to the cart
\Cart::add(
1, // any unique id
'Product 1', // product name
19.99, // product price
2, // quantity
['size' => 'large'] // an array of extra attributes
);
// get the entire cart
$cartContents = \Cart::getContent();
// update an item already in the cart
\Cart::update(
1, // the same unique id that was used to add the item
['quantity' => 3] // the quantity to update
);
// remove the item by its id
\Cart::remove(1);
// get the total of the cart
$total = Cart::getTotal();
// clear it all when you've finished (like when you've stored the order)
\Cart::clear();
```bash
composer require wearepixel/laravel-cart
```
## 📚 Documentation
## Documentation
### ⚙️ Configuration
You can publish the configuration file to customize various options.
```php
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag="config"
```
#### Formatting Numbers
The package by default does not use round to format numbers, and instead returns the number using floatval().
If you'd prefer this number to be rounded, you can customize the formatting in the configuration file.
Defaults to false.
```php
'format_numbers' => env('LARAVEL_CART_FORMAT_VALUES', false),
'round_mode' => env('LARAVEL_CART_ROUND_MODE', 'down'),
```
#### Decimals
You can customize the number of decimals in the configuration file.
Defaults to 2.
```php
'decimals' => env('LARAVEL_CART_DECIMALS', 2),
```
#### Round Mode
The package uses the `round` function to round the prices. You can customize the rounding mode in the configuration file.
Defaults to `down`.
```php
'round_mode' => env('LARAVEL_CART_ROUND_MODE', 'down'),
```
### Basic Usage
The cart has a default sessionKey that holds the cart data and stores it in the session, so you can have multiple carts for multiple users.
This also serves as a cart unique identifier which you can use to bind a cart to a specific user if you want to.
Make sure to call `\Cart::setSessionKey($sessionKey)` before calling any other cart methods.
Usually this is not required.
```php
// Binds the cart to a unique id (user id, session id, etc.)
\Cart::setSessionKey(User::first()->id);
```
#### Adding to the cart: **Cart::add()**
There are a few ways to add items to the cart.
```php
// Add a simple product to the cart
Cart::add(
455, # product id
'Sample Item', # product name
100.99, # product price
2, # quantity
[] # optional attributes
);
// array format
Cart::add([
456, # product id
'Leather Shoes', # product name
187, # product price
1, # quantity
[] # optional attributes
]);
// add an item with attributes
Cart::add([
457, // product id
'T-Shirt', // product name
29.99, // product price
1, // quantity
[
'size' => 'L',
'color' => 'Blue'
] // attributes
]);
// add an item with conditions
Cart::add([
458, // product id
'Headphones', // product name
199.99, // product price
1, // quantity
[], // attributes
[
[
'name' => '10% Off',
'type' => 'discount',
'value' => '-10%'
]
] // conditions
]);
// add multiple items at one time
Cart::add(
[
456, # product id
'Leather Shoes', # product name
187, # product price
1, # quantity
[] # optional attributes
],
[
431, # product id
'Leather Jacket', # product name
254.50, # product price
1, # quantity
[] # optional attributes
]
);
```
#### Updating an item on a cart: **Cart::update()**
```php
Cart::update(
456, # product id
[
'name' => 'New Item Name', // new item name
'price' => 98.67, // new item price as a float or string
]
);
// updating a product's quantity
Cart::update(
456, # product id
[
'quantity' => 2, // by default adding the quantity (so if from 4 to 6)
]
);
// reducing it...
Cart::update(
456,
[
'quantity' => -1, // so if from 4 to 3
]
);
// you can replace the quantity by setting relative to false
Cart::update(
456, # product id
[
'quantity' => [
'relative' => false,
'value' => 5 // if the quantity was 2, it will now be 5
],
]
);
```
#### Removing an item on a cart: **Cart::remove()**
```php
// Remove an item from the cart by its id
Cart::remove(456);
```
#### Getting an item on a cart: **Cart::get()**
```php
// Get an item from the cart by its id
Cart::get(456);
// You can also get the total price of the item
$summedPrice = Cart::get($itemId)->getPriceSum();
```
#### Getting the cart content: **Cart::getContent()**
```php
// Returns a collection of the cart's contents
$cartData = Cart::getContent();
// Gets the total number of items (not quantity) in the cart
$cartCollection->count();
// Transform the collection to an array or a JSON
$cartCollection->toArray();
$cartCollection->toJson();
```
Get cart total quantity: **Cart::getTotalQuantity()**
```php
$cartTotalQuantity = Cart::getTotalQuantity();
```
#### Cart subtotal: **Cart::getSubTotal()**
```php
$subTotal = Cart::getSubTotal();
```
#### Cart subtotal without conditions: **Cart::getSubTotalWithoutConditions()**
```php
$subTotalWithoutConditions = Cart::getSubTotalWithoutConditions();
```
Cart Total: **Cart::getTotal()**
```php
$total = Cart::getTotal();
```
#### Check if cart is empty: **Cart::isEmpty()**
```php
Cart::isEmpty();
```
#### Clearing the Cart: **Cart::clear()**
This clears all items and conditions from the cart.
```php
Cart::clear();
```
#### Clearing the cart items only: **Cart::clearItems()**
This clears all items, but keeps the conditions (useful for when you want to keep the conditions but remove the items)
```php
Cart::clearItems();
```
### Conditions
Conditions are very useful for adding things like discounts, taxes, shipping, etc.
Conditions can be added on the entire cart or on individual items, and can even be applied only at certain cart values.
Conditions on a cart level should always have a `target` of `subtotal` or `total`. This tells the cart which value to apply the condition to.
You can provide an optional `minimum` value which should be the dollar value in which the target (subtotal or total) needs to be for the condition to be active and impact the cart.
You can also provide an `order` to cart conditions which tells the cart in what order to apply the conditions. Item level conditions do not support the `order` parameter.
#### Conditions on the cart
##### Adding a condition to the cart: **Cart::condition()**
```php
// Add a single condition to the cart
$condition = new \Wearepixel\Cart\CartCondition([
'name' => 'Tax: 10%',
'type' => 'tax',
'target' => 'subtotal', // this condition will be applied to cart's subtotal when getSubTotal() is called.
'value' => '10%',
'attributes' => [ // add extra attributes here
'description' => 'Compulsory tax',
]
]);
Cart::condition($condition);
// Add multiple conditions
$tax = new \Wearepixel\Cart\CartCondition([
'name' => 'Tax: 10%',
'type' => 'tax',
'target' => 'subtotal', // this condition will be applied to cart's subtotal when getSubTotal() is called.
'value' => '10%',
'order' => 2
]);
$shipping = new \Wearepixel\Cart\CartCondition([
'name' => 'Shipping: $15',
'type' => 'shipping',
'target' => 'subtotal', // this condition will be applied to cart's subtotal when getSubTotal() is called.
'value' => '+15',
'order' => 1
]);
Cart::condition($tax);
Cart::condition($shipping);
// or as an array
Cart::condition([$tax, $shipping]);
// add condition to only apply on totals, not in subtotal
$shipping = new \Wearepixel\Cart\CartCondition([
'name' => 'Express Shipping $15',
'type' => 'shipping',
'target' => 'total',
'value' => '+15',
'order' => 1
]);
Cart::condition($shipping);
```
##### Getting conditions on the cart: **Cart::getConditions()**
```php
// To get all applied conditions on a cart, use below:
$cartConditions = Cart::getConditions();
foreach($cartConditions as $condition)
{
$condition->getTarget(); // the target of which the condition was applied
$condition->getName(); // the name of the condition
$condition->getType(); // the type
$condition->getValue(); // the value of the condition
$condition->getOrder(); // the order of the condition
$condition->getMinimum(); // the minimum dollar amount of the target, needed to activate the condition
$condition->getMaximum(); // the maximum dollar amount of the target, needed to keep the condition active
$condition->getAttributes(); // the attributes of the condition, returns an empty [] if no attributes added
}
```
##### Getting conditions on the cart as an array: **Cart::getConditions(array: true)**
You can get all cart conditions in array format by passing "array: true". This is useful if you want to store the carts conditions on a Livewire component since by default we have collections inside collections for conditions which Livewire does not support.
```php
$cartConditions = Cart::getConditions(true);
$cartConditions = Cart::getConditions(active: true);
foreach ($cartConditions as $condition) {
$condition['name']; // the name of the condition
$condition['type']; // the type
$condition['value']; // the value of the condition
$condition['order']; // the order of the condition
$condition['minimum']; // the minimum dollar amount of the target, needed to activate the condition
$condition['maximum']; // the maximum dollar amount of the target, needed to keep the condition active
$condition['attributes']; // the attributes of the condition, returns an empty [] if no attributes added
}
```
##### Getting conditions by name: **Cart::getCondition($conditionName)**
```php
$condition = Cart::getCondition('GST');
$condition->getTarget(); // the target of which the condition was applied
$condition->getName(); // the name of the condition
$condition->getType(); // the type
$condition->getValue(); // the value of the condition
$condition->getMinimum(); // the minimum dollar amount of the target, needed to activate the condition
$condition->getMaximum(); // the maximum dollar amount of the target, needed to keep the condition active
$condition->getAttributes(); // the attributes of the condition, returns an empty [] if no attributes added
```
##### Getting active conditions on the cart: **Cart::getConditions(active: true)**
You can get only active conditions by passsing `active: true` to the `getConditions()` method.
This will return conditions that are actively being applied to the cart (i.e if they meet their minimum or maximum value)
```php
$tenPercentOff = new CartCondition([
'name' => '10% Off',
'type' => 'discount',
'target' => 'subtotal',
'value' => '-10%',
'minimum' => 120,
'order' => 1,
]);
Cart::getConditions(active: true);
// will return "10% Off" if the subtotal of the cart is $200.
// will return no conditions if the subtotal is $100.
$shipping = new CartCondition([
'name' => 'Shipping',
'type' => 'discount',
'target' => 'subtotal',
'value' => '10',
'maximum' => 200,
'order' => 1,
]);
Cart::getConditions(active: true);
// will return "Shipping" if the subtotal of the cart is less than or equal 200
// will return no conditions if the subtotal is $210
```
##### Calculating condition value
There are 2 ways to calculate the value of a condition:
1. Using the `getCalculatedValue` method on the condition instance
2. Using the `getCalculatedValueForCondition` method on the cart instance and passing the condition name
##### Using the `getCalculatedValue` method on the condition instance
```php
$subTotal = Cart::getSubTotal();
$condition = Cart::getCondition('10% GST');
$conditionCalculatedValue = $condition->getCalculatedValue($subTotal);
```
##### Using the `getCalculatedValueForCondition` method on the cart instance
This method automatically calculates the value of a condition by it's name based on the order of the conditions.
```php
Cart::add([
'id' => 1,
'name' => 'Apple iPhone 15',
'price' => 200,
'quantity' => 1,
'attributes' => [],
]);
$couponDiscount = new CartCondition([
'name' => 'Coupon Discount',
'type' => 'discount',
'target' => 'subtotal',
'value' => '-200',
'order' => 1,
]);
$giftCard = new CartCondition([
'name' => 'Gift Card',
'type' => 'discount',
'target' => 'subtotal',
'value' => '-200',
'order' => 2,
]);
Cart::getCalculatedValueForCondition('Coupon Discount'); // returns 200
Cart::getCalculatedValueForCondition('Gift Card'); // returns 0 as the coupon discount is applied first and brings the subtotal to 0
```
##### Adding conditions that activate once a minimum value is met
You can add a `minimum` amount required for a condition to activate.
This is useful for applying discounts only after certain cart values, i.e: 10% off for any purchases over $120.
```php
$tenPercentOff = new CartCondition([
'name' => '10% Off',
'type' => 'discount',
'target' => 'subtotal',
'value' => '-10%',
'minimum' => 120,
'order' => 1,
]);
Cart::condition($tenPercentOff)
```
##### Adding conditions that only activate up to a maximum value
You can add a `maximum` amount required for a condition to activate.
This is useful for applying discounts up until amounts, i.e shipping for anything below $200, and free shipping above.
```php
$shipping = new CartCondition([
'name' => 'Shipping',
'type' => 'discount',
'target' => 'subtotal',
'value' => '12',
'maximum' => 200,
'order' => 1,
]);
Cart::condition($shipping)
```
#### Conditions on items
Item conditions are useful if you have discounts to be applied specifically on an item and not on the whole cart value.
```php
// lets create first our condition instance
$saleCondition = new \Wearepixel\Cart\CartCondition([
'name' => '50% Off',
'type' => 'tax',
'value' => '-50%',
]);
// Create the product data with the condition
$product = [
'id' => 456,
'name' => 'Sample Item 1',
'price' => 100,
'quantity' => 1,
'attributes' => [],
'conditions' => $saleCondition
];
// Now add the product to the cart
Cart::add($product);
// You can of course also do multiple conditions on an item
$saleCondition = new \Wearepixel\Cart\CartCondition([
'name' => 'SALE 5%',
'type' => 'sale',
'value' => '-5%',
]);
$discountCode = new CartCondition([
'name' => 'Discount Code',
'type' => 'promo',
'value' => '-25',
]);
$item = [
'id' => 456,
'name' => 'Sample Item 1',
'price' => 100,
'quantity' => 1,
'attributes' => [],
'conditions' => [$saleCondition, $discountCode]
];
Cart::add($item);
```
> NOTE: All cart per-item conditions should be added before calling **Cart::getSubTotal()**
Then Finally you can call **Cart::getSubTotal()** to get the Cart sub total with the applied conditions on each of the items.
```php
// the subtotal will be calculated based on the conditions added that has target => "subtotal"
// and also conditions that are added on per item
$cartSubTotal = Cart::getSubTotal();
```
##### Add a condition to an existing item on the cart: **Cart::addItemCondition($productId, $itemCondition)**
Adding Condition to an existing Item on the cart is simple as well.
```php
$condition = new CartCondition([
'name' => 'COUPON 101',
'type' => 'coupon',
'value' => '-5%',
]);
Cart::addItemCondition(456, $condition);
```
#### Clearing Cart Conditions: **Cart::clearCartConditions()**
This clears all cart level conditions, and does not affect item level conditions.
```php
Cart::clearCartConditions()
```
If you wish to clear all conditions from all items and the cart, use **Cart::clearAllConditions()**
```php
Cart::clearAllConditions()
```
##### Remove a specific cart condtion: **Cart::removeCartCondition($conditionName)**
```php
Cart::removeCartCondition('Summer Sale 5%')
```
##### Remove a specific item condition: **Cart::removeItemCondition($itemId, $conditionName)**
```php
Cart::removeItemCondition(456, 'SALE 5%')
```
##### Clear all item conditions: **Cart::clearItemConditions($itemId)**
```php
Cart::clearItemConditions(456)
```
#### Get conditions by type: **Cart::getConditionsByType($type)**
This returns all conditions that has been added to the cart by the type specified.
```php
$tax = Cart::getConditionsByType('tax');
```
##### Remove conditions by type: **Cart::removeConditionsByType($type)**
```php
Cart::removeConditionsByType('tax');
```
### Cart Items
The method **Cart::getContent()** returns a collection of items.
Apart from the above methods, you can also get the price of an item with or without **item level conditions** applied.
These methods do not apply cart level conditions.
```php
// With no conditions, just the price * quantity
$item->getPriceSum();
// With conditions applied get the price of a single quantity
$item->getPriceWithConditions();
// Get the sum with conditions applied
$item->getPriceSumWithConditions();
// Without conditions applied
$item->getPriceSumWithConditions();
```
### Storage Options
By default the cart is stored in the session, but there are times you may want to store the cart in the database.
For instance, you may want to store the cart in the database so that the cart can be retrieved even after the user logs out or closes the browser, or you may want to add cart timeouts and support for multiple computers.
#### Session
The cart is stored in the session by default, using Laravel's in-built SessionManager.
#### Database Support
To get started, you'll need to create a new model for your Cart, the package requires only 3 columns, but you're free to extend this as you wish and add more columns.
You can utilise the [events](#events) provided by the package to store additional information alongside the cart.
```php
$table->string('session_id'); // this handles the session id of the cart
$table->text('items'); // this will store the cart items
$table->text('conditions'); // this will store the cart level conditions
```
Then add some json casts to your model and fillable columns:
```php
protected $guarded = [];
protected $casts = [
'items' => 'array',
'conditions' => 'array',
];
```
Then update the configuration file to use the database driver:
```php
'driver' => 'database',
'storage' => [
'session',
'database' => [
'model' => \App\Models\Cart::class, // your model here
'id' => 'session_id',
'items' => 'items',
'conditions' => 'conditions',
],
],
```
### Events
The package provides a few events that you can listen to in order to manipulate the cart or take actions based on cart events.
#### LaravelCart.Created
This fires every time a cart is instantiated (i.e every time \Cart::add() is called)
```
Event::listen('LaravelCart.Added', function () {
// cart was created
});
```
#### LaravelCart.Adding
This fires every time an item is being added to the cart
```
Event::listen('LaravelCart.Adding', function ($item) {
// item is being added
});
```
#### LaravelCart.Added
This fires every time an item is successfully added to the cart
```
Event::listen('LaravelCart.Added', function ($item) {
// item was added
});
```
#### LaravelCart.Updating
This fires every time an item is being updated
```
Event::listen('LaravelCart.Updating', function ($item) {
// item is being updated
});
```
#### LaravelCart.Updated
This fires every time an item is successfully updated
```
Event::listen('LaravelCart.Updated', function ($item) {
// item was updated
});
```
#### LaravelCart.Removing
This fires every time an item is being removed
```
Event::listen('LaravelCart.Removing', function ($item) {
// item is being removed
});
```
#### LaravelCart.Removed
This fires every time an item is successfully removed
```
Event::listen('LaravelCart.Removed', function ($item) {
// item was removed
});
```
#### LaravelCart.Clearing
This fires every time the cart is being cleared
```
Event::listen('LaravelCart.Clearing', function () {
// cart is being cleared
});
```
#### LaravelCart.Cleared
This fires every time the cart is successfully cleared
```
Event::listen('LaravelCart.Cleared', function () {
// cart was cleared
});
```
Full documentation, including configuration, usage, conditions, storage drivers, testing, and the v2 upgrade guide, is available at [wearepixel.com.au/docs/laravel-cart](https://wearepixel.com.au/docs/laravel-cart/v3/introduction).
## 🫡 Credits
+165
View File
@@ -0,0 +1,165 @@
# Upgrade Guide
## v2.x to v3.0
### Breaking changes
#### Constructor signature
`Cart` no longer accepts a raw session/database storage object as its first constructor argument. It now requires a `CartDriver` instance. If you were resolving `Cart` directly (not via the service container), update your instantiation:
**Before:**
```php
new Cart($sessionStorage, $events, 'cart', $sessionKey, $config);
```
**After:**
```php
use Wearepixel\Cart\Drivers\SessionDriver;
new Cart(new SessionDriver($session, $sessionKey), $events, 'cart', $config);
```
Applications that use the service container (the normal case) are unaffected - `CartServiceProvider` handles this automatically.
#### Publish tag renamed
The config publish tag changed from `config` to `cart-config` to avoid conflicts with other packages:
**Before:**
```bash
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag=config
```
**After:**
```bash
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag=cart-config
# or simply:
php artisan cart:install
```
#### CartSession removed
`Wearepixel\Cart\CartSession` has been removed. It is replaced by the driver system. If you extended or referenced `CartSession` directly, switch to the appropriate driver (`SessionDriver`, `DatabaseDriver`, etc.).
### Non-breaking additions
The following are additive and require no changes to existing code:
- **Storage drivers:** `SessionDriver`, `DatabaseDriver`, `RedisDriver`, `NullDriver`, `MultiDriver` - configure via `config/cart.php`
- **CartManager:** resolves drivers by name, accessible via `app('cart.manager')`
- **First-class objects:** `Coupon`, `TaxRule`, `ShippingRate` abstract base classes - optional, existing raw `CartCondition` usage continues to work
- **Cart::fake():** returns a `CartFactory` backed by an in-memory `NullDriver` for testing
- **Assertion methods:** `Cart::assertContains()`, `assertCount()`, `assertTotal()`, etc.
- **HasCart trait:** Livewire reactive cart state
- **ShareCartWithInertia:** static class for Inertia middleware integration
- **Artisan generators:** `cart:install`, `cart:make:coupon`, `cart:make:tax`, `cart:make:shipping`, `cart:make:driver`, `cart:debug`
---
### Recommended v3 patterns
v3 is fully backwards compatible - your existing code will continue to work unchanged. The following patterns are recommended for new code and worth migrating to when you touch existing cart logic.
#### 1. Publish the updated config
Run the installer to get the new `driver` key and storage configuration in your `config/cart.php`:
```bash
php artisan cart:install
```
The default driver is `session`, so behaviour is identical to v2 unless you change it.
#### 2. Replace raw CartCondition arrays with first-class objects
Generate typed classes with Artisan instead of constructing arrays inline:
```bash
php artisan cart:make:coupon TenPercentOff
php artisan cart:make:tax GstTaxRule
php artisan cart:make:shipping FlatRateShipping
```
**Before:**
```php
$condition = new CartCondition([
'name' => 'SAVE10',
'type' => 'coupon',
'target' => 'subtotal',
'value' => '-10%',
]);
Cart::condition($condition);
```
**After:**
```php
// app/Cart/Coupons/TenPercentOff.php
class TenPercentOff extends Coupon
{
protected string $code = 'SAVE10';
protected string $value = '-10%';
protected string $target = 'subtotal';
public function isValid(): bool
{
return true;
}
}
Cart::coupon(new TenPercentOff);
Cart::tax(new GstTaxRule);
Cart::shipping(new FlatRateShipping);
```
The same pattern applies to `TaxRule` (implement `isApplicable()`) and `ShippingRate` (implement `isApplicable()`).
#### 3. Use Cart::fake() in tests instead of mocking
**Before:**
```php
// Mocking the session or binding a fake driver manually
$this->mock(CartDriver::class, function ($mock) {
$mock->shouldReceive(...);
});
```
**After:**
```php
$factory = app('cart')->fake();
$factory->withItems(3)->withCondition(new GstTaxRule);
$cart = app('cart');
$cart->assertCount(3);
$cart->assertConditionApplied('GST');
$cart->assertTotal(33.00);
```
`Cart::fake()` swaps the driver to an in-memory `NullDriver` for the duration of the test - no session, database, or Redis needed.
#### 4. Switch to a persistent driver (optional)
If you want cart state to survive beyond a session, update `config/cart.php`:
```php
'driver' => 'database', // or 'redis'
```
For the database driver, publish and run the migration:
```bash
php artisan cart:install
php artisan migrate
```
See the [Storage Drivers](README.md#storage-drivers) section of the README for full configuration options.
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "wearepixel/laravel-cart",
"version": "2.1.2",
"version": "3.0.0",
"description": "A cart implementation for Laravel",
"keywords": [
"laravel",
@@ -30,7 +30,8 @@
"mockery/mockery": "^2.0",
"laravel/pint": "^1.21",
"pestphp/pest": "^4.7",
"larapack/dd": "dev-master"
"larapack/dd": "dev-master",
"phpstan/phpstan": "2.2.x-dev"
},
"autoload": {
"psr-4": {
Generated
+80 -14
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "36458db92bb67aee0a12a15f588349b4",
"content-hash": "adce585bac784ac7803d7be6f7172a3f",
"packages": [
{
"name": "brick/math",
@@ -5415,16 +5415,16 @@
},
{
"name": "laravel/pint",
"version": "v1.29.1",
"version": "v1.32.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
"reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
"reference": "006bd7825eb01daf92959d060b89cd9514fe4253"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
"reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"url": "https://api.github.com/repos/laravel/pint/zipball/006bd7825eb01daf92959d060b89cd9514fe4253",
"reference": "006bd7825eb01daf92959d060b89cd9514fe4253",
"shasum": ""
},
"require": {
@@ -5432,17 +5432,19 @@
"ext-mbstring": "*",
"ext-tokenizer": "*",
"ext-xml": "*",
"php": "^8.2.0"
"php": "^8.3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.95.1",
"illuminate/view": "^12.56.0",
"larastan/larastan": "^3.9.6",
"laravel-zero/framework": "^12.1.0",
"mockery/mockery": "^1.6.12",
"composer/semver": "^3.4.4",
"friendsofphp/php-cs-fixer": "^3.95.25",
"illuminate/view": "^13.31.0",
"larastan/larastan": "^3.11.0",
"laravel-zero/framework": "^13.0.0",
"laravel/agent-detector": "^2.0.2",
"laravel/prompts": "^0.3.24",
"mockery/mockery": "^1.6.15",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest": "^3.8.6",
"shipfastlabs/agent-detector": "^1.1.3"
"pestphp/pest": "^4.7.8"
},
"bin": [
"builds/pint"
@@ -5479,7 +5481,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
"time": "2026-04-20T15:26:14+00:00"
"time": "2026-09-10T10:47:55+00:00"
},
{
"name": "mockery/mockery",
@@ -6518,6 +6520,70 @@
},
"time": "2026-05-25T15:58:15+00:00"
},
{
"name": "phpstan/phpstan",
"version": "2.3.4",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f20ce6f3c82556cfcfc086b152445a12d1d64fff",
"reference": "f20ce6f3c82556cfcfc086b152445a12d1d64fff",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0"
},
"conflict": {
"phpstan/phpstan-shim": "*"
},
"bin": [
"phpstan",
"phpstan.phar"
],
"type": "library",
"autoload": {
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ondřej Mirtes"
},
{
"name": "Markus Staab"
},
{
"name": "Vincent Langlet"
}
],
"description": "PHPStan - PHP Static Analysis Tool",
"keywords": [
"dev",
"static analysis"
],
"support": {
"docs": "https://phpstan.org/user-guide/getting-started",
"forum": "https://github.com/phpstan/phpstan/discussions",
"issues": "https://github.com/phpstan/phpstan/issues",
"security": "https://github.com/phpstan/phpstan/security/policy",
"source": "https://github.com/phpstan/phpstan-src"
},
"funding": [
{
"url": "https://github.com/ondrejmirtes",
"type": "github"
},
{
"url": "https://github.com/phpstan",
"type": "github"
}
],
"time": "2026-10-28T22:38:53+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "12.5.x-dev",
+15 -1
View File
@@ -16,11 +16,11 @@
* ---------------------------------------------------------------
* Storage
* ---------------------------------------------------------------
* Supported: "session", "database", "redis", "null", "multi"
*/
'driver' => 'session',
'storage' => [
'session',
'database' => [
'model' => '',
'id' => '',
@@ -28,4 +28,18 @@
'conditions' => '',
],
],
/*
* ---------------------------------------------------------------
* Driver-specific options
* ---------------------------------------------------------------
*/
'drivers' => [
'redis' => [
'connection' => 'default',
'ttl' => 604800, // 7 days in seconds
],
'multi' => ['session', 'database'],
],
];
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* PHPStan bootstrap — stubs for Laravel global helpers that are always
* available at runtime in a Laravel application but are not present in the
* standalone illuminate/* packages this package depends on.
*/
if (! function_exists('app')) {
function app(string $abstract = null, array $parameters = []): mixed
{
return null;
}
}
if (! function_exists('config')) {
function config(string|null $key = null, mixed $default = null): mixed
{
return null;
}
}
if (! function_exists('config_path')) {
function config_path(string $path = ''): string
{
return '';
}
}
if (! function_exists('app_path')) {
function app_path(string $path = ''): string
{
return '';
}
}
if (! function_exists('session')) {
function session(string|array|null $key = null, mixed $default = null): mixed
{
return null;
}
}
+14
View File
@@ -0,0 +1,14 @@
parameters:
level: 5
phpVersion: 80300
paths:
- src
bootstrapFiles:
- phpstan-bootstrap.php
ignoreErrors:
# HasCart is a trait for external use in Livewire components — not used within this package.
- identifier: trait.unused
# PHPStan 2.x bundles PHP 8.4 stubs where PHP_ROUND_HALF_UP/DOWN map to 1/2 instead of 0/1.
# The code is correct at runtime on PHP 8.3. Remove once PHPStan stubs are corrected.
- message: '#Parameter \#3 \$mode of function round expects#'
path: src/Helpers/Helpers.php
+2 -1
View File
@@ -7,6 +7,7 @@
"class_attributes_separation": false,
"ordered_imports": {
"sort_algorithm": "length"
}
},
"not_operator_with_space": false
}
}
+265 -413
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -78,6 +78,14 @@ public function getMaximum(): int|float|null
return $this->args['maximum'] ?? null;
}
/**
* Get the number of items this condition applies to (null = all items)
*/
public function getAppliesToQuantity(): int|null
{
return isset($this->args['applies_to']) ? (int) $this->args['applies_to'] : null;
}
/**
* get the additional attributes of a condition
*
@@ -105,13 +113,11 @@ public function setValue($value): void
}
/**
* Set the order to apply this condition. If no argument order is applied we return 0 as
* indicator that no assignment has been made
* Set the order to apply this condition.
*
* @param int $order
* @return int
*/
public function setOrder($order = 1)
public function setOrder($order = 1): void
{
$this->args['order'] = $order;
}
@@ -139,8 +145,6 @@ public function applyCondition($totalOrSubTotalOrPrice)
/**
* get the calculated value of this condition supplied by the subtotal|price
*
* @return mixed
*/
public function getCalculatedValue($totalOrSubTotalOrPrice = null): int|float
{
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart;
use Illuminate\Support\Manager;
use Wearepixel\Cart\Drivers\MultiDriver;
use Wearepixel\Cart\Drivers\NullDriver;
use Wearepixel\Cart\Drivers\RedisDriver;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Drivers\DatabaseDriver;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class CartManager extends Manager
{
public function getDefaultDriver(): string
{
return $this->config->get('cart.driver', 'session');
}
private function sessionKey(): string
{
return $this->container['session']->getId();
}
public function createSessionDriver(): CartDriver
{
return new SessionDriver(
$this->container['session'],
$this->sessionKey(),
);
}
public function createDatabaseDriver(): CartDriver
{
$config = $this->config->get('cart.storage.database');
return new DatabaseDriver(
$config['model'],
$config['id'],
$config['items'],
$config['conditions'],
$this->sessionKey(),
);
}
public function createRedisDriver(): CartDriver
{
$config = $this->config->get('cart.drivers.redis', []);
return new RedisDriver(
$this->container['redis'],
$this->sessionKey(),
$config['connection'] ?? 'default',
$config['ttl'] ?? 604800,
);
}
public function createNullDriver(): CartDriver
{
return new NullDriver($this->sessionKey());
}
public function createMultiDriver(): CartDriver
{
$names = $this->config->get('cart.drivers.multi', ['session', 'database']);
$drivers = array_map(fn(string $name) => $this->driver($name), $names);
return new MultiDriver($drivers);
}
}
+25 -35
View File
@@ -1,63 +1,53 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart;
use Illuminate\Support\ServiceProvider;
use Wearepixel\Cart\Commands\DebugCommand;
use Wearepixel\Cart\Commands\InstallCommand;
use Wearepixel\Cart\Commands\MakeCouponCommand;
use Wearepixel\Cart\Commands\MakeDriverCommand;
use Wearepixel\Cart\Commands\MakeShippingCommand;
use Wearepixel\Cart\Commands\MakeTaxCommand;
class CartServiceProvider extends ServiceProvider
{
/**
* Indicates if loading of the provider is deferred.
*
* @var bool
*/
protected $defer = false;
/**
* Boot the service provider.
*/
public function boot()
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/config.php' => config_path('cart.php'),
], 'config');
], 'cart-config');
$this->commands([
InstallCommand::class,
MakeCouponCommand::class,
MakeTaxCommand::class,
MakeShippingCommand::class,
MakeDriverCommand::class,
DebugCommand::class,
]);
}
}
/**
* Register the service provider.
*
* @return void
*/
public function register()
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'cart');
$this->app->singleton('cart.manager', fn($app) => new CartManager($app));
$this->app->singleton('cart', function ($app) {
$config = config('cart');
$events = $app['events'];
$driver = $app['cart.manager']->driver();
$storage = $config['driver'] === 'database'
? new $config['storage']['database']['model']
: $app['session'];
return new Cart(
$storage,
$events,
'cart',
session()->getId(),
$config
);
return new Cart($driver, $events, 'cart', $config);
});
}
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
public function provides(): array
{
return [];
}
-174
View File
@@ -1,174 +0,0 @@
<?php
namespace Wearepixel\Cart;
class CartSession
{
private $customStorage;
public $sessionKey;
public $driver;
private $session;
private $sessionId = null;
private $itemsKey = null;
private $conditionsKey = null;
public function __construct($session, $sessionKey, $config)
{
$this->sessionKey = $sessionKey;
$this->driver = $config['driver'];
if ($this->driver === 'session') {
$this->customStorage = false;
$this->session = $session;
$this->itemsKey = $this->sessionKey . '_cart_items';
$this->conditionsKey = $this->sessionKey . '_cart_conditions';
} elseif ($this->driver === 'database') {
$this->customStorage = true;
$this->sessionId = $config['storage']['database']['id'];
$this->itemsKey = $config['storage']['database']['items'];
$this->conditionsKey = $config['storage']['database']['conditions'];
$this->session = $session->firstOrNew([$this->sessionId => $this->sessionKey]);
$this->session[$this->itemsKey] = $this->session[$this->itemsKey] ?? [];
$this->session[$this->conditionsKey] = $this->session[$this->conditionsKey] ?? [];
}
}
public function isInternalSession($session)
{
return in_array(is_object($session) ? get_class($session) : $session, [
'Illuminate\Session\SessionManager',
'Wearepixel\Cart\Tests\Helpers\SessionMock',
]);
}
public function getSessionKey()
{
return $this->sessionKey;
}
public function setSessionKey(string $sessionKey)
{
// set the session key and then update the session
$this->sessionKey = $sessionKey;
if ($this->driver == 'session') {
$session = $this->session->get($this->itemsKey);
$sessionKey = $this->session->get($this->conditionsKey);
$this->session->forget($this->itemsKey);
$this->session->forget($this->conditionsKey);
$this->itemsKey = $this->sessionKey . '_cart_items';
$this->conditionsKey = $this->sessionKey . '_cart_conditions';
$this->session->put($this->itemsKey, $session);
$this->session->put($this->conditionsKey, $sessionKey);
} elseif ($this->driver == 'database') {
$this->session[$this->sessionId] = $sessionKey;
$this->session->save();
}
}
public function has($key)
{
return $this->session->has($key) ?? $this->session;
}
public function getItems()
{
return $this->driver == 'session' ? $this->session->get($this->itemsKey) : $this->session[$this->itemsKey];
}
public function getConditions()
{
return $this->driver == 'session' ? $this->session->get($this->conditionsKey) : $this->session[$this->conditionsKey];
}
public function putItems($value)
{
if ($this->driver == 'session') {
return $this->session->put($this->itemsKey, $value);
}
// convert any conditions to arrays
$value = is_array($value) ? $value : $value->toArray();
foreach ($value as $key => $item) {
if ($item['conditions']) {
if (is_array($item['conditions'])) {
$value[$key]['conditions'] = collect($item['conditions'])->map(function ($condition) {
return $condition->toArray();
});
} else {
$value[$key]['conditions'] = $item['conditions']->toArray();
}
}
}
$this->session[$this->itemsKey] = $value;
$this->session->save();
return $this->session;
}
public function putConditions($value)
{
if ($this->driver == 'session') {
return $this->session->put($this->conditionsKey, $value);
}
if (! empty($value)) {
$value = $value->mapWithKeys(function ($condition) {
return [$condition->getName() => $condition->toArray()];
});
}
$this->session[$this->conditionsKey] = $value;
$this->session->save();
return $this->session;
}
public function clearItems()
{
if ($this->driver == 'session') {
$this->session->forget($this->itemsKey);
return true;
}
$this->session[$this->itemsKey] = [];
return $this->session->save();
}
public function clear()
{
if ($this->driver == 'session') {
$this->session->forget($this->itemsKey);
$this->session->forget($this->conditionsKey);
return true;
}
$deleted = $this->session->delete();
$this->session = [
$this->itemsKey => [],
$this->conditionsKey => [],
];
return $deleted;
}
public function getSessionModel()
{
return $this->session;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
use Wearepixel\Cart\Cart;
class DebugCommand extends Command
{
protected $signature = 'cart:debug';
protected $description = 'Dump the current cart state. Only runs outside production.';
public function handle(): int
{
if (app()->isProduction()) {
$this->error('cart:debug is not available in production.');
return self::FAILURE;
}
$cart = app('cart');
$data = static::debugData($cart);
$this->info('Cart Debug');
$this->line('');
$this->line(" Driver: <comment>{$data['driver']}</comment>");
$this->line(" Items: <comment>{$data['item_count']}</comment>");
$this->line(" Total Quantity: <comment>{$data['total_quantity']}</comment>");
$this->line(" Subtotal: <comment>{$data['subtotal']}</comment>");
$this->line(" Total: <comment>{$data['total']}</comment>");
if (! empty($data['conditions'])) {
$this->line(" Conditions: <comment>" . implode(', ', $data['conditions']) . '</comment>');
} else {
$this->line(' Conditions: <comment>none</comment>');
}
if (! empty($data['items'])) {
$this->line('');
$this->table(['ID', 'Name', 'Price', 'Qty'], $data['items']);
}
return self::SUCCESS;
}
public static function debugData(Cart $cart): array
{
$items = $cart->getContent();
return [
'driver' => $cart->getDriver()->getDriverName(),
'item_count' => $items->count(),
'total_quantity' => (int) $cart->getTotalQuantity(),
'subtotal' => (float) $cart->getSubTotal(false),
'total' => (float) $cart->getTotal(),
'conditions' => $cart->getConditions()->map(fn($c) => $c->getName())->values()->toArray(),
'items' => $items->map(fn($item) => [
$item->id,
$item->name,
$item->price,
$item->quantity,
])->values()->toArray(),
];
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class InstallCommand extends Command
{
protected $signature = 'cart:install';
protected $description = 'Publish the cart config and scaffold the app/Cart directory structure.';
public function handle(): int
{
$this->call('vendor:publish', ['--tag' => 'cart-config', '--ansi' => true]);
static::scaffoldDirectories(app_path('Cart'));
$this->info('Cart installed successfully.');
$this->newLine();
$this->line(' Next steps:');
$this->line(' 1. Review <comment>config/cart.php</comment> and set your preferred storage driver.');
$this->line(' 2. Run <comment>php artisan cart:make:coupon MyCoupon</comment> to create your first coupon.');
$this->line(' 3. Run <comment>php artisan cart:make:tax MyTax</comment> to add a tax rule.');
$this->line(' 4. Run <comment>php artisan cart:make:shipping MyShipping</comment> to add a shipping rate.');
return self::SUCCESS;
}
public static function scaffoldDirectories(string $base): void
{
foreach (['Coupons', 'Tax', 'Shipping', 'Drivers'] as $subdir) {
$path = "{$base}/{$subdir}";
if (! is_dir($path)) {
mkdir($path, 0755, true);
}
}
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class MakeCouponCommand extends Command
{
protected $signature = 'cart:make:coupon {name : The class name of the coupon}';
protected $description = 'Create a new Coupon class in app/Cart/Coupons.';
public function handle(): int
{
$name = $this->argument('name');
if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) {
$this->error("Invalid class name: [{$name}]. Must be a valid PHP class name.");
return self::FAILURE;
}
$directory = app_path('Cart/Coupons');
$path = "{$directory}/{$name}.php";
if (file_exists($path)) {
$this->error("Coupon already exists: app/Cart/Coupons/{$name}.php");
return self::FAILURE;
}
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
if (file_put_contents($path, static::stub($name)) === false) {
$this->error("Failed to write file: {$path}");
return self::FAILURE;
}
$this->info("Coupon created: app/Cart/Coupons/{$name}.php");
return self::SUCCESS;
}
public static function stub(string $className): string
{
return <<<PHP
<?php
declare(strict_types=1);
namespace App\Cart\Coupons;
use Wearepixel\Cart\Coupons\Coupon;
class {$className} extends Coupon
{
protected string \$code = 'COUPON_CODE';
protected string \$value = '-10%';
protected string \$target = 'subtotal';
public function isValid(): bool
{
return true;
}
}
PHP;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class MakeDriverCommand extends Command
{
protected $signature = 'cart:make:driver {name : The class name of the custom cart driver}';
protected $description = 'Create a new CartDriver implementation in app/Cart/Drivers.';
public function handle(): int
{
$name = $this->argument('name');
if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) {
$this->error("Invalid class name: [{$name}]. Must be a valid PHP class name.");
return self::FAILURE;
}
$directory = app_path('Cart/Drivers');
$path = "{$directory}/{$name}.php";
if (file_exists($path)) {
$this->error("Driver already exists: app/Cart/Drivers/{$name}.php");
return self::FAILURE;
}
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
if (file_put_contents($path, static::stub($name)) === false) {
$this->error("Failed to write file: {$path}");
return self::FAILURE;
}
$this->info("Driver created: app/Cart/Drivers/{$name}.php");
$this->line(" Register it with <comment>Cart::extend()</comment> in a service provider's boot() method, then set 'driver' to its name in config/cart.php.");
return self::SUCCESS;
}
public static function stub(string $className): string
{
return <<<PHP
<?php
declare(strict_types=1);
namespace App\Cart\Drivers;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class {$className} implements CartDriver
{
private string \$sessionKey;
public function __construct(string \$sessionKey)
{
\$this->sessionKey = \$sessionKey;
}
public function getSessionKey(): string
{
return \$this->sessionKey;
}
public function setSessionKey(string \$sessionKey): void
{
\$this->sessionKey = \$sessionKey;
}
public function getDriverName(): string
{
return 'custom';
}
public function getItems(): array
{
// TODO: retrieve items from your storage backend
return [];
}
public function putItems(array \$items): void
{
// TODO: persist items to your storage backend
}
public function getConditions(): array
{
// TODO: retrieve conditions from your storage backend
return [];
}
public function putConditions(array \$conditions): void
{
// TODO: persist conditions to your storage backend
}
public function clearItems(): void
{
// TODO: remove only items from your storage backend
}
public function flush(): void
{
// TODO: remove both items and conditions from your storage backend
}
public function getSessionModel(): mixed
{
return null;
}
}
PHP;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class MakeShippingCommand extends Command
{
protected $signature = 'cart:make:shipping {name : The class name of the shipping rate}';
protected $description = 'Create a new ShippingRate class in app/Cart/Shipping.';
public function handle(): int
{
$name = $this->argument('name');
if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) {
$this->error("Invalid class name: [{$name}]. Must be a valid PHP class name.");
return self::FAILURE;
}
$directory = app_path('Cart/Shipping');
$path = "{$directory}/{$name}.php";
if (file_exists($path)) {
$this->error("Shipping rate already exists: app/Cart/Shipping/{$name}.php");
return self::FAILURE;
}
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
if (file_put_contents($path, static::stub($name)) === false) {
$this->error("Failed to write file: {$path}");
return self::FAILURE;
}
$this->info("Shipping rate created: app/Cart/Shipping/{$name}.php");
return self::SUCCESS;
}
public static function stub(string $className): string
{
return <<<PHP
<?php
declare(strict_types=1);
namespace App\Cart\Shipping;
use Wearepixel\Cart\Shipping\ShippingRate;
class {$className} extends ShippingRate
{
protected string \$name = 'Standard Shipping';
protected string \$value = '+10';
protected string \$target = 'total';
public function isApplicable(): bool
{
return true;
}
}
PHP;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Commands;
use Illuminate\Console\Command;
class MakeTaxCommand extends Command
{
protected $signature = 'cart:make:tax {name : The class name of the tax rule}';
protected $description = 'Create a new TaxRule class in app/Cart/Tax.';
public function handle(): int
{
$name = $this->argument('name');
if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) {
$this->error("Invalid class name: [{$name}]. Must be a valid PHP class name.");
return self::FAILURE;
}
$directory = app_path('Cart/Tax');
$path = "{$directory}/{$name}.php";
if (file_exists($path)) {
$this->error("Tax rule already exists: app/Cart/Tax/{$name}.php");
return self::FAILURE;
}
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
if (file_put_contents($path, static::stub($name)) === false) {
$this->error("Failed to write file: {$path}");
return self::FAILURE;
}
$this->info("Tax rule created: app/Cart/Tax/{$name}.php");
return self::SUCCESS;
}
public static function stub(string $className): string
{
return <<<PHP
<?php
declare(strict_types=1);
namespace App\Cart\Tax;
use Wearepixel\Cart\Tax\TaxRule;
class {$className} extends TaxRule
{
protected string \$name = 'Tax Name';
protected string \$value = '10%';
protected string \$target = 'subtotal';
public function isApplicable(): bool
{
return true;
}
}
PHP;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Concerns;
use Wearepixel\Cart\Cart;
trait HasCart
{
public array $cartItems = [];
public float $cartTotal = 0.0;
public float $cartSubTotal = 0.0;
public int $cartCount = 0;
protected function getCart(): Cart
{
return app('cart');
}
public function refreshCart(): void
{
$cart = $this->getCart();
$this->cartItems = $cart->getContent()->toArray();
$this->cartSubTotal = (float) $cart->getSubTotal(false);
$this->cartTotal = (float) $cart->getTotal();
$this->cartCount = (int) $cart->getTotalQuantity();
}
public function cartAdd(
int|string $id,
string $name,
float $price,
int $quantity = 1,
array $attributes = [],
): void {
$this->getCart()->add($id, $name, $price, $quantity, $attributes);
$this->refreshCart();
}
public function cartRemove(int|string $id): void
{
$this->getCart()->remove($id);
$this->refreshCart();
}
public function cartUpdate(int|string $id, array $data): void
{
$this->getCart()->update($id, $data);
$this->refreshCart();
}
public function cartClear(): void
{
$this->getCart()->clear();
$this->refreshCart();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Concerns;
use Wearepixel\Cart\Cart;
class ShareCartWithInertia
{
public static function data(?Cart $cart = null): array
{
$cart ??= app('cart');
return [
'items' => $cart->getContent()->toArray(),
'subtotal' => (float) $cart->getSubTotal(false),
'total' => (float) $cart->getTotal(),
'count' => (int) $cart->getTotalQuantity(),
];
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Contracts;
use Wearepixel\Cart\CartCondition;
interface CartConditionable
{
public function toCondition(): CartCondition;
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Coupons;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Contracts\CartConditionable;
use Wearepixel\Cart\Exceptions\InvalidCouponException;
abstract class Coupon implements CartConditionable
{
protected string $code;
protected string $value;
protected string $target = 'subtotal';
protected ?float $minimum = null;
protected ?float $maximum = null;
abstract public function isValid(): bool;
public function getCode(): string
{
return $this->code;
}
public function toCondition(): CartCondition
{
if (! $this->isValid()) {
throw new InvalidCouponException("Coupon [{$this->code}] is not valid.");
}
$data = [
'name' => $this->code,
'type' => 'coupon',
'value' => $this->value,
'target' => $this->target,
];
if ($this->minimum !== null) {
$data['minimum'] = $this->minimum;
}
if ($this->maximum !== null) {
$data['maximum'] = $this->maximum;
}
return new CartCondition($data);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers\Contracts;
interface CartDriver
{
public function getSessionKey(): string;
public function setSessionKey(string $sessionKey): void;
public function getDriverName(): string;
public function getItems(): array;
public function putItems(array $items): void;
public function getConditions(): array;
public function putConditions(array $conditions): void;
public function clearItems(): void;
public function flush(): void;
public function getSessionModel(): mixed;
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use Illuminate\Database\Eloquent\Model;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class DatabaseDriver implements CartDriver
{
private Model $model;
public function __construct(
private string $modelClass,
private string $idColumn,
private string $itemsColumn,
private string $conditionsColumn,
private string $sessionKey,
) {
$this->model = (new $modelClass)->firstOrNew([$idColumn => $sessionKey]);
$this->model[$itemsColumn] ??= [];
$this->model[$conditionsColumn] ??= [];
}
public function getSessionKey(): string
{
return $this->sessionKey;
}
public function setSessionKey(string $sessionKey): void
{
$this->sessionKey = $sessionKey;
$this->model[$this->idColumn] = $sessionKey;
$this->model->save();
}
public function getDriverName(): string
{
return 'database';
}
public function getItems(): array
{
return $this->model[$this->itemsColumn] ?? [];
}
public function putItems(array $items): void
{
$this->model[$this->itemsColumn] = $items;
$this->model->save();
}
public function getConditions(): array
{
return $this->model[$this->conditionsColumn] ?? [];
}
public function putConditions(array $conditions): void
{
$this->model[$this->conditionsColumn] = $conditions;
$this->model->save();
}
public function clearItems(): void
{
$this->model[$this->itemsColumn] = [];
$this->model->save();
}
public function flush(): void
{
$this->model->delete();
$this->model[$this->itemsColumn] = [];
$this->model[$this->conditionsColumn] = [];
}
public function getSessionModel(): mixed
{
return $this->model;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use InvalidArgumentException;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class MultiDriver implements CartDriver
{
/** @param CartDriver[] $drivers */
public function __construct(private array $drivers)
{
if (empty($drivers)) {
throw new InvalidArgumentException('MultiDriver requires at least one driver.');
}
}
private function primary(): CartDriver
{
return $this->drivers[0];
}
public function getSessionKey(): string
{
return $this->primary()->getSessionKey();
}
public function setSessionKey(string $sessionKey): void
{
foreach ($this->drivers as $driver) {
$driver->setSessionKey($sessionKey);
}
}
public function getDriverName(): string
{
return 'multi';
}
public function getItems(): array
{
return $this->primary()->getItems();
}
public function putItems(array $items): void
{
foreach ($this->drivers as $driver) {
$driver->putItems($items);
}
}
public function getConditions(): array
{
return $this->primary()->getConditions();
}
public function putConditions(array $conditions): void
{
foreach ($this->drivers as $driver) {
$driver->putConditions($conditions);
}
}
public function clearItems(): void
{
foreach ($this->drivers as $driver) {
$driver->clearItems();
}
}
public function flush(): void
{
foreach ($this->drivers as $driver) {
$driver->flush();
}
}
public function getSessionModel(): mixed
{
return $this->primary()->getSessionModel();
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class NullDriver implements CartDriver
{
private array $items = [];
private array $conditions = [];
public function __construct(private string $sessionKey) {}
public function getSessionKey(): string
{
return $this->sessionKey;
}
public function setSessionKey(string $sessionKey): void
{
$this->sessionKey = $sessionKey;
}
public function getDriverName(): string
{
return 'null';
}
public function getItems(): array
{
return $this->items;
}
public function putItems(array $items): void
{
$this->items = $items;
}
public function getConditions(): array
{
return $this->conditions;
}
public function putConditions(array $conditions): void
{
$this->conditions = $conditions;
}
public function clearItems(): void
{
$this->items = [];
}
public function flush(): void
{
$this->items = [];
$this->conditions = [];
}
public function getSessionModel(): mixed
{
return null;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class RedisDriver implements CartDriver
{
public function __construct(
private mixed $redis,
private string $sessionKey,
private string $connection = 'default',
private int $ttl = 604800,
) {}
private function itemsKey(): string
{
return "cart:{$this->sessionKey}:items";
}
private function conditionsKey(): string
{
return "cart:{$this->sessionKey}:conditions";
}
public function getSessionKey(): string
{
return $this->sessionKey;
}
public function setSessionKey(string $sessionKey): void
{
$items = $this->getItems();
$conditions = $this->getConditions();
$this->flush();
$this->sessionKey = $sessionKey;
if (! empty($items)) {
$this->putItems($items);
}
if (! empty($conditions)) {
$this->putConditions($conditions);
}
}
public function getDriverName(): string
{
return 'redis';
}
public function getItems(): array
{
$data = $this->redis->connection($this->connection)->get($this->itemsKey());
return $data ? (json_decode($data, true) ?? []) : [];
}
public function putItems(array $items): void
{
$this->redis->connection($this->connection)->setex(
$this->itemsKey(),
$this->ttl,
json_encode($items),
);
}
public function getConditions(): array
{
$data = $this->redis->connection($this->connection)->get($this->conditionsKey());
return $data ? (json_decode($data, true) ?? []) : [];
}
public function putConditions(array $conditions): void
{
$this->redis->connection($this->connection)->setex(
$this->conditionsKey(),
$this->ttl,
json_encode($conditions),
);
}
public function clearItems(): void
{
$this->redis->connection($this->connection)->del($this->itemsKey());
}
public function flush(): void
{
$this->redis->connection($this->connection)->del($this->itemsKey(), $this->conditionsKey());
}
public function getSessionModel(): mixed
{
return null;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Drivers;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
class SessionDriver implements CartDriver
{
private string $itemsKey;
private string $conditionsKey;
public function __construct(
private mixed $session = null,
private ?string $sessionKey = null,
) {
$this->session ??= app('session');
$this->sessionKey ??= $this->session->getId();
$this->itemsKey = $this->sessionKey . '_cart_items';
$this->conditionsKey = $this->sessionKey . '_cart_conditions';
}
public function getSessionKey(): string
{
return $this->sessionKey;
}
public function setSessionKey(string $sessionKey): void
{
$items = $this->session->get($this->itemsKey);
$conditions = $this->session->get($this->conditionsKey);
$this->session->forget($this->itemsKey);
$this->session->forget($this->conditionsKey);
$this->sessionKey = $sessionKey;
$this->itemsKey = $sessionKey . '_cart_items';
$this->conditionsKey = $sessionKey . '_cart_conditions';
if ($items !== null) {
$this->session->put($this->itemsKey, $items);
}
if ($conditions !== null) {
$this->session->put($this->conditionsKey, $conditions);
}
}
public function getDriverName(): string
{
return 'session';
}
public function getItems(): array
{
return $this->session->get($this->itemsKey) ?? [];
}
public function putItems(array $items): void
{
$this->session->put($this->itemsKey, $items);
}
public function getConditions(): array
{
return $this->session->get($this->conditionsKey) ?? [];
}
public function putConditions(array $conditions): void
{
$this->session->put($this->conditionsKey, $conditions);
}
public function clearItems(): void
{
$this->session->forget($this->itemsKey);
}
public function flush(): void
{
$this->session->forget($this->itemsKey);
$this->session->forget($this->conditionsKey);
}
public function getSessionModel(): mixed
{
return null;
}
}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidCouponException extends \Exception {}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidShippingRateException extends \Exception {}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidTaxRuleException extends \Exception {}
+37 -9
View File
@@ -7,32 +7,60 @@
use Wearepixel\Cart\CartCollection;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\CartConditionCollection;
use Wearepixel\Cart\Coupons\Coupon;
use Wearepixel\Cart\Drivers\Contracts\CartDriver;
use Wearepixel\Cart\ItemCollection;
use Wearepixel\Cart\Shipping\ShippingRate;
use Wearepixel\Cart\Tax\TaxRule;
use Wearepixel\Cart\Testing\CartFactory;
/**
* @mixin Cart
*
* @method static CartDriver getDriver()
* @method static CartFactory fake()
* @method static void assertContains(int|string $id)
* @method static void assertCount(int $count)
* @method static void assertTotalQuantity(int|float $quantity)
* @method static void assertSubTotal(float $amount)
* @method static void assertTotal(float $amount)
* @method static void assertConditionApplied(string $conditionName)
* @method static void assertEmpty()
* @method static void assertNotEmpty()
* @method static string getSessionKey()
* @method static void setSessionKey(string $sessionKey)
* @method static string getInstanceName()
* @method static ItemCollection|null get(int|string $itemId)
* @method static bool has(int|string $itemId)
* @method static void add(int|string $id, string $name, int|float $price, int $quantity, array $attributes = [], CartCondition|array $conditions = [], string $associatedModel = '')
* @method static Cart add(string|int|array $id, string|null $name = null, float|null $price = null, int|float|null $quantity = null, array $attributes = [], CartCondition|array $conditions = [], string|null $associatedModel = null)
* @method static bool removeInvalidItems()
* @method static bool update(int|string $id, array $data)
* @method static void remove(int|string $id)
* @method static void clear()
* @method static void clearItems()
* @method static void setConfig(array $config)
* @method static Cart addItemCondition(int|string $productId, CartCondition $itemCondition)
* @method static bool remove(int|string $id)
* @method static bool clear()
* @method static bool clearItems()
* @method static Cart condition(array|CartCondition $condition)
* @method static Cart coupon(Coupon $coupon)
* @method static Cart tax(TaxRule $taxRule)
* @method static Cart shipping(ShippingRate $shippingRate)
* @method static CartConditionCollection|array getConditions(bool $array = false, bool $active = false)
* @method static CartCondition|null getCondition(string $conditionName)
* @method static CartConditionCollection getConditionsByType(string $type)
* @method static void removeConditionsByType(string $type)
* @method static void removeCartCondition(string $conditionName)
* @method static void clearItemConditions(int|string $itemId)
* @method static bool removeItemCondition(int|string $itemId, string $conditionName)
* @method static bool clearItemConditions(int|string $itemId)
* @method static void clearCartConditions()
* @method static void clearAllConditions()
* @method static int|float|string getSubTotalWithoutConditions(bool $formatted = true)
* @method static int|float|string getSubTotal(bool $formatted = true)
* @method static int|float getTotal()
* @method static int|float getCalculatedValueForCondition(string $conditionName)
* @method static int|float getTotalQuantity()
* @method static CartCollection getContent()
* @method static bool isEmpty()
* @method static int getTotalQuantity()
* @method static int|float|string getSubTotal(bool $formatted = true)
* @method static int|float|string getSubTotalWithoutConditions(bool $formatted = true)
* @method static int|float getTotal()
* @method static Cart associate(mixed $model)
*/
class CartFacade extends Facade
{
+37 -4
View File
@@ -5,6 +5,14 @@
use Illuminate\Support\Collection;
use Wearepixel\Cart\Helpers\Helpers;
/**
* @property mixed $id
* @property string $name
* @property float|int $price
* @property int|float $quantity
* @property array<string, mixed> $attributes
* @property mixed $conditions
*/
class ItemCollection extends Collection
{
/**
@@ -46,9 +54,9 @@ public function __get($name)
/**
* return the associated model of an item
*
* @return bool
* @return mixed
*/
protected function getAssociatedModel()
protected function getAssociatedModel(): mixed
{
if (! $this->has('associatedModel')) {
return null;
@@ -97,7 +105,7 @@ public function getConditions()
// check if we're already an array of CartConditions
foreach ($conditions as $key => $condition) {
if ($condition instanceof CartCondition) {
if (is_object($condition) && $condition instanceof CartCondition) {
return $conditions;
}
}
@@ -105,7 +113,7 @@ public function getConditions()
$hasSubArray = false;
foreach ($conditions as $key => $condition) {
if (is_array($condition) && ! $condition instanceof CartCondition) {
if (is_array($condition)) {
$hasSubArray = true;
$conditionsArray[] = new CartCondition($condition);
} elseif ($condition instanceof CartCondition) {
@@ -162,6 +170,31 @@ public function getPriceWithConditions($formatted = true)
*/
public function getPriceSumWithConditions($formatted = true)
{
$conditions = $this->hasConditions() ? $this->getConditions() : [];
$conditions = is_array($conditions) ? $conditions : [$conditions];
$hasLimit = collect($conditions)->some(fn ($c) => $c->getAppliesToQuantity() !== null);
if (! $hasLimit) {
return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config);
}
$total = 0.0;
for ($i = 1; $i <= $this->quantity; $i++) {
$price = $this->price;
foreach ($conditions as $condition) {
$limit = $condition->getAppliesToQuantity();
if ($limit === null || $i <= $limit) {
$price = $condition->applyCondition($price);
}
}
$total += $price;
}
return Helpers::formatValue($total, $formatted, $this->config);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Shipping;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Contracts\CartConditionable;
use Wearepixel\Cart\Exceptions\InvalidShippingRateException;
abstract class ShippingRate implements CartConditionable
{
protected string $name;
protected string $value;
protected string $target = 'total';
protected ?float $minimum = null;
protected ?float $maximum = null;
abstract public function isApplicable(): bool;
public function toCondition(): CartCondition
{
if (! $this->isApplicable()) {
throw new InvalidShippingRateException("Shipping rate [{$this->name}] is not applicable.");
}
$data = [
'name' => $this->name,
'type' => 'shipping',
'value' => $this->value,
'target' => $this->target,
];
if ($this->minimum !== null) {
$data['minimum'] = $this->minimum;
}
if ($this->maximum !== null) {
$data['maximum'] = $this->maximum;
}
return new CartCondition($data);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Tax;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Contracts\CartConditionable;
use Wearepixel\Cart\Exceptions\InvalidTaxRuleException;
abstract class TaxRule implements CartConditionable
{
protected string $name;
protected string $value;
protected string $target = 'subtotal';
protected ?float $minimum = null;
protected ?float $maximum = null;
abstract public function isApplicable(): bool;
public function toCondition(): CartCondition
{
if (! $this->isApplicable()) {
throw new InvalidTaxRuleException("Tax rule [{$this->name}] is not applicable.");
}
$data = [
'name' => $this->name,
'type' => 'tax',
'value' => $this->value,
'target' => $this->target,
];
if ($this->minimum !== null) {
$data['minimum'] = $this->minimum;
}
if ($this->maximum !== null) {
$data['maximum'] = $this->maximum;
}
return new CartCondition($data);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Testing;
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Coupons\Coupon;
class CartFactory
{
public function __construct(private Cart $cart) {}
public function withItems(int $count): static
{
$start = $this->cart->getContent()->count() + 1;
for ($i = $start; $i < $start + $count; $i++) {
$this->cart->add($i, "Test Item {$i}", 10.00, 1);
}
return $this;
}
public function withCondition(CartCondition $condition): static
{
$this->cart->condition($condition);
return $this;
}
public function withCoupon(Coupon $coupon): static
{
$this->cart->coupon($coupon);
return $this;
}
}
+3
View File
@@ -7,6 +7,9 @@
use Illuminate\Translation\FileLoader;
use Illuminate\Translation\Translator;
/**
* @method static \Illuminate\Validation\Validator make(array $data, array $rules, array $messages = [], array $attributes = [])
*/
abstract class Validator
{
protected static $factory;
+4 -4
View File
@@ -3,6 +3,7 @@
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\CartConditionCollection;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
@@ -10,10 +11,9 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigConditionsMock.php')
);
});
@@ -950,8 +950,8 @@
expect($this->cart->getCalculatedValueForCondition('Coupon Discount'))->toEqual(200.0, 'Coupon Discount should be 200.0');
expect($this->cart->getCalculatedValueForCondition('Gift Card'))->toEqual(0, 'Gift Card should be 0');
expect($couponDiscount->getCalculatedValue())->toEqual(200.0, 'Coupon Discount value should be 200.0');
expect($giftCard->getCalculatedValue())->toEqual(0, 'Gift Card value should be 0');
expect($this->cart->getCondition('Coupon Discount')->getCalculatedValue(200.0))->toEqual(200.0, 'Coupon Discount value should be 200.0');
expect($this->cart->getCondition('Gift Card')->getCalculatedValue(0))->toEqual(0, 'Gift Card value should be 0');
});
test('calculate the subtotal correctly when one condition makes it $0, and the other adds $10', function () {
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Coupons\Coupon;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Exceptions\InvalidCouponException;
use Wearepixel\Cart\Shipping\ShippingRate;
use Wearepixel\Cart\Tax\TaxRule;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
function makeCart(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch');
return new Cart(
new SessionDriver(new SessionMock, 'TESTKEY'),
$events,
'cart',
require __DIR__ . '/../Helpers/ConfigMock.php',
);
}
class TestCoupon extends Coupon
{
protected string $code = 'SAVE10';
protected string $value = '-10%';
public function isValid(): bool { return true; }
}
class InvalidTestCoupon extends Coupon
{
protected string $code = 'EXPIRED';
protected string $value = '-10%';
public function isValid(): bool { return false; }
}
class TestTax extends TaxRule
{
protected string $name = 'GST';
protected string $value = '10%';
public function isApplicable(): bool { return true; }
}
class TestShipping extends ShippingRate
{
protected string $name = 'Flat Rate';
protected string $value = '+10';
public function isApplicable(): bool { return true; }
}
beforeEach(function () {
$this->cart = makeCart();
$this->cart->add(1, 'Widget', 100.00, 1);
});
afterEach(fn() => Mockery::close());
describe('Cart first-class objects', function () {
test('Cart::coupon() applies condition from a Coupon instance', function () {
$this->cart->coupon(new TestCoupon);
expect($this->cart->getCondition('SAVE10'))->toBeInstanceOf(CartCondition::class);
expect($this->cart->getSubTotal())->toEqual(90.00);
});
test('Cart::coupon() throws when coupon is invalid', function () {
expect(fn() => $this->cart->coupon(new InvalidTestCoupon))
->toThrow(InvalidCouponException::class);
});
test('Cart::tax() applies condition from a TaxRule instance', function () {
$this->cart->tax(new TestTax);
expect($this->cart->getCondition('GST'))->toBeInstanceOf(CartCondition::class);
expect($this->cart->getSubTotal())->toEqual(110.00);
});
test('Cart::shipping() applies condition from a ShippingRate instance', function () {
$this->cart->shipping(new TestShipping);
expect($this->cart->getCondition('Flat Rate'))->toBeInstanceOf(CartCondition::class);
expect($this->cart->getTotal())->toEqual(110.00);
});
test('raw Cart::condition() still works alongside first-class objects', function () {
$this->cart->coupon(new TestCoupon);
$this->cart->condition(new CartCondition([
'name' => 'Manual Discount',
'type' => 'discount',
'value' => '-5',
'target' => 'subtotal',
]));
expect($this->cart->getConditions()->count())->toBe(2);
});
});
+2 -2
View File
@@ -1,6 +1,7 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\MockProduct;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
@@ -9,10 +10,9 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
+7 -12
View File
@@ -1,6 +1,7 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {});
@@ -14,10 +15,9 @@
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true);
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -31,10 +31,9 @@
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Added', Mockery::type('array'), true);
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -50,10 +49,9 @@
$events->shouldReceive('dispatch')->times(2)->with('LaravelCart.Added', Mockery::type('array'), true);
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -94,10 +92,9 @@
];
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -139,10 +136,9 @@
];
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -186,10 +182,9 @@
];
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
+3 -4
View File
@@ -1,6 +1,7 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
@@ -8,18 +9,16 @@
$events->shouldReceive('dispatch');
$this->cart1 = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'uniquesessionkey123'),
$events,
'shopping',
'uniquesessionkey123',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
$this->cart2 = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'uniquesessionkey456'),
$events,
'wishlist',
'uniquesessionkey456',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
+71
View File
@@ -0,0 +1,71 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Commands\DebugCommand;
use Wearepixel\Cart\Drivers\NullDriver;
function makeDebugCart(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch');
return new Cart(
new NullDriver('debug-session'),
$events,
'cart',
require __DIR__ . '/../../Helpers/ConfigMock.php',
);
}
afterEach(fn() => Mockery::close());
describe('DebugCommand::debugData()', function () {
test('returns driver name', function () {
$cart = makeDebugCart();
$data = DebugCommand::debugData($cart);
expect($data['driver'])->toBe('null');
});
test('returns item count', function () {
$cart = makeDebugCart();
$cart->add(1, 'Widget', 10.00, 1);
$data = DebugCommand::debugData($cart);
expect($data['item_count'])->toBe(1);
});
test('returns total quantity', function () {
$cart = makeDebugCart();
$cart->add(1, 'Widget', 10.00, 3);
$data = DebugCommand::debugData($cart);
expect($data['total_quantity'])->toBe(3);
});
test('returns subtotal', function () {
$cart = makeDebugCart();
$cart->add(1, 'Widget', 25.00, 2);
$data = DebugCommand::debugData($cart);
expect($data['subtotal'])->toBe(50.00);
});
test('returns total', function () {
$cart = makeDebugCart();
$cart->add(1, 'Widget', 25.00, 2);
$data = DebugCommand::debugData($cart);
expect($data['total'])->toBe(50.00);
});
test('returns empty items array when cart is empty', function () {
$cart = makeDebugCart();
$data = DebugCommand::debugData($cart);
expect($data['items'])->toBeArray()->toBeEmpty();
});
test('returns condition names', function () {
$cart = makeDebugCart();
$cart->condition(new \Wearepixel\Cart\CartCondition([
'name' => 'GST', 'type' => 'tax', 'value' => '10%', 'target' => 'subtotal',
]));
$data = DebugCommand::debugData($cart);
expect($data['conditions'])->toContain('GST');
});
});
@@ -0,0 +1,33 @@
<?php
use Wearepixel\Cart\Commands\InstallCommand;
describe('InstallCommand', function () {
test('scaffoldDirectories() creates the expected subdirectories', function () {
$base = sys_get_temp_dir() . '/cart-install-' . uniqid();
InstallCommand::scaffoldDirectories($base);
expect(is_dir("{$base}/Coupons"))->toBeTrue();
expect(is_dir("{$base}/Tax"))->toBeTrue();
expect(is_dir("{$base}/Shipping"))->toBeTrue();
expect(is_dir("{$base}/Drivers"))->toBeTrue();
foreach (["{$base}/Coupons", "{$base}/Tax", "{$base}/Shipping", "{$base}/Drivers", $base] as $dir) {
@rmdir($dir);
}
});
test('scaffoldDirectories() is idempotent - does not throw if dirs already exist', function () {
$base = sys_get_temp_dir() . '/cart-install-idem-' . uniqid();
InstallCommand::scaffoldDirectories($base);
InstallCommand::scaffoldDirectories($base);
expect(is_dir("{$base}/Coupons"))->toBeTrue();
foreach (["{$base}/Coupons", "{$base}/Tax", "{$base}/Shipping", "{$base}/Drivers", $base] as $dir) {
@rmdir($dir);
}
});
});
@@ -0,0 +1,30 @@
<?php
use Wearepixel\Cart\Commands\MakeCouponCommand;
describe('MakeCouponCommand', function () {
test('stub contains the given class name', function () {
$stub = MakeCouponCommand::stub('TenPercentCoupon');
expect($stub)->toContain('class TenPercentCoupon extends Coupon');
});
test('stub uses the App\\Cart\\Coupons namespace', function () {
$stub = MakeCouponCommand::stub('TenPercentCoupon');
expect($stub)->toContain('namespace App\\Cart\\Coupons');
});
test('stub imports the Coupon base class', function () {
$stub = MakeCouponCommand::stub('TenPercentCoupon');
expect($stub)->toContain('use Wearepixel\\Cart\\Coupons\\Coupon');
});
test('stub declares isValid() method', function () {
$stub = MakeCouponCommand::stub('TenPercentCoupon');
expect($stub)->toContain('public function isValid(): bool');
});
test('stub uses strict_types', function () {
$stub = MakeCouponCommand::stub('TenPercentCoupon');
expect($stub)->toContain('declare(strict_types=1)');
});
});
@@ -0,0 +1,34 @@
<?php
use Wearepixel\Cart\Commands\MakeDriverCommand;
describe('MakeDriverCommand', function () {
test('stub contains the given class name', function () {
$stub = MakeDriverCommand::stub('RedisDriver');
expect($stub)->toContain('class RedisDriver implements CartDriver');
});
test('stub uses the App\\Cart\\Drivers namespace', function () {
$stub = MakeDriverCommand::stub('RedisDriver');
expect($stub)->toContain('namespace App\\Cart\\Drivers');
});
test('stub imports the CartDriver interface', function () {
$stub = MakeDriverCommand::stub('RedisDriver');
expect($stub)->toContain('use Wearepixel\\Cart\\Drivers\\Contracts\\CartDriver');
});
test('stub declares all CartDriver interface methods', function () {
$stub = MakeDriverCommand::stub('RedisDriver');
expect($stub)->toContain('public function getSessionKey(): string');
expect($stub)->toContain('public function setSessionKey(string $sessionKey): void');
expect($stub)->toContain('public function getDriverName(): string');
expect($stub)->toContain('public function getItems(): array');
expect($stub)->toContain('public function putItems(array $items): void');
expect($stub)->toContain('public function getConditions(): array');
expect($stub)->toContain('public function putConditions(array $conditions): void');
expect($stub)->toContain('public function clearItems(): void');
expect($stub)->toContain('public function flush(): void');
expect($stub)->toContain('public function getSessionModel(): mixed');
});
});
@@ -0,0 +1,30 @@
<?php
use Wearepixel\Cart\Commands\MakeShippingCommand;
describe('MakeShippingCommand', function () {
test('stub contains the given class name', function () {
$stub = MakeShippingCommand::stub('FlatRateShipping');
expect($stub)->toContain('class FlatRateShipping extends ShippingRate');
});
test('stub uses the App\\Cart\\Shipping namespace', function () {
$stub = MakeShippingCommand::stub('FlatRateShipping');
expect($stub)->toContain('namespace App\\Cart\\Shipping');
});
test('stub imports the ShippingRate base class', function () {
$stub = MakeShippingCommand::stub('FlatRateShipping');
expect($stub)->toContain('use Wearepixel\\Cart\\Shipping\\ShippingRate');
});
test('stub declares isApplicable() method', function () {
$stub = MakeShippingCommand::stub('FlatRateShipping');
expect($stub)->toContain('public function isApplicable(): bool');
});
test('stub defaults target to total', function () {
$stub = MakeShippingCommand::stub('FlatRateShipping');
expect($stub)->toContain("'total'");
});
});
@@ -0,0 +1,30 @@
<?php
use Wearepixel\Cart\Commands\MakeTaxCommand;
describe('MakeTaxCommand', function () {
test('stub contains the given class name', function () {
$stub = MakeTaxCommand::stub('GstTaxRule');
expect($stub)->toContain('class GstTaxRule extends TaxRule');
});
test('stub uses the App\\Cart\\Tax namespace', function () {
$stub = MakeTaxCommand::stub('GstTaxRule');
expect($stub)->toContain('namespace App\\Cart\\Tax');
});
test('stub imports the TaxRule base class', function () {
$stub = MakeTaxCommand::stub('GstTaxRule');
expect($stub)->toContain('use Wearepixel\\Cart\\Tax\\TaxRule');
});
test('stub declares isApplicable() method', function () {
$stub = MakeTaxCommand::stub('GstTaxRule');
expect($stub)->toContain('public function isApplicable(): bool');
});
test('stub uses strict_types', function () {
$stub = MakeTaxCommand::stub('GstTaxRule');
expect($stub)->toContain('declare(strict_types=1)');
});
});
+87
View File
@@ -0,0 +1,87 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Concerns\HasCart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
class FakeComponent
{
use HasCart;
public array $cartItems = [];
public float $cartTotal = 0.0;
public float $cartSubTotal = 0.0;
public int $cartCount = 0;
public function __construct(private Cart $cart) {}
protected function getCart(): Cart
{
return $this->cart;
}
}
function makeComponent(): FakeComponent
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch');
$cart = new Cart(
new SessionDriver(new SessionMock, 'TESTKEY'),
$events,
'cart',
require __DIR__ . '/../../Helpers/ConfigMock.php',
);
return new FakeComponent($cart);
}
beforeEach(function () {
$this->component = makeComponent();
});
afterEach(fn() => Mockery::close());
describe('HasCart trait', function () {
test('refreshCart() populates cartItems', function () {
$this->component->cartAdd(1, 'Widget', 50.00, 2);
expect($this->component->cartItems)->not->toBeEmpty();
});
test('cartAdd() adds an item and refreshes state', function () {
$this->component->cartAdd(1, 'Widget', 50.00, 1);
expect($this->component->cartCount)->toBe(1);
expect($this->component->cartSubTotal)->toBe(50.00);
});
test('cartRemove() removes an item and refreshes state', function () {
$this->component->cartAdd(1, 'Widget', 50.00, 1);
$this->component->cartRemove(1);
expect($this->component->cartCount)->toBe(0);
});
test('cartUpdate() updates an item and refreshes state', function () {
$this->component->cartAdd(1, 'Widget', 50.00, 1);
$this->component->cartUpdate(1, ['quantity' => ['relative' => false, 'value' => 3]]);
expect($this->component->cartCount)->toBe(3);
});
test('cartClear() empties the cart and refreshes state', function () {
$this->component->cartAdd(1, 'Widget', 50.00, 1);
$this->component->cartClear();
expect($this->component->cartCount)->toBe(0);
expect($this->component->cartItems)->toBeEmpty();
});
test('cartTotal reflects the cart total', function () {
$this->component->cartAdd(1, 'Widget', 100.00, 1);
expect($this->component->cartTotal)->toBe(100.00);
});
});
@@ -0,0 +1,54 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Concerns\ShareCartWithInertia;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
function makeCartForInertia(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch');
return new Cart(
new SessionDriver(new SessionMock, 'TESTKEY'),
$events,
'cart',
require __DIR__ . '/../../Helpers/ConfigMock.php',
);
}
beforeEach(function () {
$this->cart = makeCartForInertia();
});
afterEach(fn() => Mockery::close());
describe('ShareCartWithInertia', function () {
test('data() returns items, total, subtotal, and count', function () {
$this->cart->add(1, 'Widget', 50.00, 2);
$data = ShareCartWithInertia::data($this->cart);
expect($data)->toHaveKeys(['items', 'total', 'subtotal', 'count']);
expect($data['count'])->toBe(2);
expect($data['subtotal'])->toBe(100.00);
expect($data['total'])->toBe(100.00);
});
test('data() returns empty state when cart is empty', function () {
$data = ShareCartWithInertia::data($this->cart);
expect($data['items'])->toBeEmpty();
expect($data['count'])->toBe(0);
});
test('data() items are plain arrays suitable for JSON serialization', function () {
$this->cart->add(1, 'Widget', 50.00, 1);
$data = ShareCartWithInertia::data($this->cart);
expect($data['items'])->toBeArray();
expect(json_encode($data))->toBeString();
});
});
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Coupons\Coupon;
use Wearepixel\Cart\Exceptions\InvalidCouponException;
class AlwaysValidCoupon extends Coupon
{
protected string $code = 'SAVE10';
protected string $value = '-10%';
public function isValid(): bool
{
return true;
}
}
class NeverValidCoupon extends Coupon
{
protected string $code = 'EXPIRED';
protected string $value = '-10%';
public function isValid(): bool
{
return false;
}
}
describe('Coupon', function () {
test('converts to a CartCondition', function () {
$condition = (new AlwaysValidCoupon)->toCondition();
expect($condition)->toBeInstanceOf(CartCondition::class);
expect($condition->getName())->toBe('SAVE10');
expect($condition->getValue())->toBe('-10%');
expect($condition->getType())->toBe('coupon');
expect($condition->getTarget())->toBe('subtotal');
});
test('respects custom target', function () {
$coupon = new class extends Coupon {
protected string $code = 'TOTAL10';
protected string $value = '-10%';
protected string $target = 'total';
public function isValid(): bool { return true; }
};
expect($coupon->toCondition()->getTarget())->toBe('total');
});
test('includes minimum in condition when set', function () {
$coupon = new class extends Coupon {
protected string $code = 'BIG10';
protected string $value = '-10%';
protected ?float $minimum = 100.0;
public function isValid(): bool { return true; }
};
expect($coupon->toCondition()->getMinimum())->toBe(100.0);
});
test('isValid() returning false causes toCondition() to throw', function () {
expect(fn() => (new NeverValidCoupon)->toCondition())
->toThrow(InvalidCouponException::class);
});
test('getCode returns the coupon code', function () {
expect((new AlwaysValidCoupon)->getCode())->toBe('SAVE10');
});
});
+59 -4
View File
@@ -2,19 +2,23 @@
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\DatabaseDriver;
use Wearepixel\Cart\Tests\Helpers\MockCartModel;
beforeEach(function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch');
$storage = new MockCartModel;
$this->cart = new Cart(
$storage,
new DatabaseDriver(
MockCartModel::class,
'session_id',
'items',
'conditions',
'SAMPLESESSIONKEY',
),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigDatabaseMock.php')
);
});
@@ -108,6 +112,38 @@
expect($this->cart->getTotal())->toEqual(18.45, 'Cart should have total of 18.45');
});
test('cart-level conditions survive database round-trip', function () {
$this->cart->condition(new CartCondition([
'name' => 'GST',
'type' => 'tax',
'value' => '10%',
'target' => 'subtotal',
]));
// Create a fresh cart instance pointing at the same DB row
$events2 = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events2->shouldReceive('dispatch');
$cart2 = new \Wearepixel\Cart\Cart(
new \Wearepixel\Cart\Drivers\DatabaseDriver(
\Wearepixel\Cart\Tests\Helpers\MockCartModel::class,
'session_id',
'items',
'conditions',
'SAMPLESESSIONKEY',
),
$events2,
'cart',
require (__DIR__ . '/../Helpers/ConfigDatabaseMock.php')
);
$condition = $cart2->getCondition('GST');
expect($condition)->not->toBeNull();
expect($condition->getName())->toBe('GST');
expect($condition->getValue())->toBe('10%');
});
test('can clear a simple cart', function () {
$this->cart->add(1, 'Sample Item', 100.99, 2, []);
@@ -123,4 +159,23 @@
expect($this->cart->getContent()->count())->toEqual(0, 'Cart content should be 0');
expect(MockCartModel::all()->count())->toEqual(0, 'The carts database table should have 0 rows');
});
test('can clear cart conditions after clearing the cart', function () {
$condition = new CartCondition([
'name' => '10% Off',
'type' => 'coupon',
'target' => 'subtotal',
'value' => '-10%',
]);
$this->cart->add(1, 'Sample Item', 100.99, 2, []);
$this->cart->condition($condition);
$this->cart->clear();
// This was throwing "Call to a member function save() on array"
// because clear() replaced $this->session with a plain array
expect(fn () => $this->cart->clearCartConditions())->not->toThrow(Error::class);
expect($this->cart->getConditions())->toBeEmpty();
});
});
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Drivers\DatabaseDriver;
use Wearepixel\Cart\Tests\Helpers\MockCartModel;
beforeEach(function () {
$this->driver = new DatabaseDriver(
MockCartModel::class,
'session_id',
'items',
'conditions',
'test-session',
);
});
describe('DatabaseDriver', function () {
test('returns its session key', function () {
expect($this->driver->getSessionKey())->toBe('test-session');
});
test('returns database as driver name', function () {
expect($this->driver->getDriverName())->toBe('database');
});
test('starts with empty items', function () {
expect($this->driver->getItems())->toBe([]);
});
test('stores and retrieves items', function () {
$items = [['id' => 1, 'name' => 'Widget']];
$this->driver->putItems($items);
$fresh = new DatabaseDriver(MockCartModel::class, 'session_id', 'items', 'conditions', 'test-session');
expect($fresh->getItems())->toBe($items);
});
test('starts with empty conditions', function () {
expect($this->driver->getConditions())->toBe([]);
});
test('stores and retrieves conditions', function () {
$conditions = [['name' => 'GST', 'type' => 'tax', 'value' => '10%']];
$this->driver->putConditions($conditions);
$fresh = new DatabaseDriver(MockCartModel::class, 'session_id', 'items', 'conditions', 'test-session');
expect($fresh->getConditions())->toBe($conditions);
});
test('flush deletes the database record', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->flush();
expect(MockCartModel::count())->toBe(0);
});
test('clearItems empties items but keeps conditions', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->putConditions([['name' => 'GST']]);
$this->driver->clearItems();
$fresh = new DatabaseDriver(MockCartModel::class, 'session_id', 'items', 'conditions', 'test-session');
expect($fresh->getItems())->toBe([]);
expect($fresh->getConditions())->toBe([['name' => 'GST']]);
});
test('getSessionModel returns the Eloquent model', function () {
expect($this->driver->getSessionModel())->toBeInstanceOf(MockCartModel::class);
});
test('setSessionKey updates the session key on the model', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->setSessionKey('new-session');
expect($this->driver->getSessionKey())->toBe('new-session');
expect($this->driver->getItems())->toBe([['id' => 1]]);
});
});
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Drivers\MultiDriver;
use Wearepixel\Cart\Drivers\NullDriver;
describe('MultiDriver', function () {
beforeEach(function () {
$this->primary = new NullDriver('test-session');
$this->secondary = new NullDriver('test-session');
$this->driver = new MultiDriver([$this->primary, $this->secondary]);
});
test('returns session key from primary driver', function () {
expect($this->driver->getSessionKey())->toBe('test-session');
});
test('returns multi as driver name', function () {
expect($this->driver->getDriverName())->toBe('multi');
});
test('reads items from primary driver', function () {
$this->primary->putItems([['id' => 1]]);
$this->secondary->putItems([['id' => 2]]);
expect($this->driver->getItems())->toBe([['id' => 1]]);
});
test('writes items to all drivers', function () {
$this->driver->putItems([['id' => 1]]);
expect($this->primary->getItems())->toBe([['id' => 1]]);
expect($this->secondary->getItems())->toBe([['id' => 1]]);
});
test('reads conditions from primary driver', function () {
$this->primary->putConditions([['name' => 'GST']]);
$this->secondary->putConditions([['name' => 'Other']]);
expect($this->driver->getConditions())->toBe([['name' => 'GST']]);
});
test('writes conditions to all drivers', function () {
$this->driver->putConditions([['name' => 'GST']]);
expect($this->primary->getConditions())->toBe([['name' => 'GST']]);
expect($this->secondary->getConditions())->toBe([['name' => 'GST']]);
});
test('clearItems clears items on all drivers', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->clearItems();
expect($this->primary->getItems())->toBe([]);
expect($this->secondary->getItems())->toBe([]);
});
test('flush flushes all drivers', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->putConditions([['name' => 'GST']]);
$this->driver->flush();
expect($this->primary->getItems())->toBe([]);
expect($this->primary->getConditions())->toBe([]);
expect($this->secondary->getItems())->toBe([]);
expect($this->secondary->getConditions())->toBe([]);
});
test('setSessionKey updates all drivers', function () {
$this->driver->setSessionKey('new-key');
expect($this->primary->getSessionKey())->toBe('new-key');
expect($this->secondary->getSessionKey())->toBe('new-key');
});
test('throws when constructed with no drivers', function () {
expect(fn() => new MultiDriver([]))->toThrow(InvalidArgumentException::class);
});
test('getSessionModel delegates to primary driver', function () {
expect($this->driver->getSessionModel())->toBeNull();
});
});
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Drivers\NullDriver;
describe('NullDriver', function () {
beforeEach(function () {
$this->driver = new NullDriver('test-session');
});
test('returns its session key', function () {
expect($this->driver->getSessionKey())->toBe('test-session');
});
test('returns \'null\' as driver name', function () {
expect($this->driver->getDriverName())->toBe('null');
});
test('starts with empty items', function () {
expect($this->driver->getItems())->toBe([]);
});
test('stores and retrieves items', function () {
$this->driver->putItems([['id' => 1, 'name' => 'Widget']]);
expect($this->driver->getItems())->toBe([['id' => 1, 'name' => 'Widget']]);
});
test('starts with empty conditions', function () {
expect($this->driver->getConditions())->toBe([]);
});
test('stores and retrieves conditions', function () {
$this->driver->putConditions([['name' => 'GST', 'type' => 'tax', 'value' => '10%']]);
expect($this->driver->getConditions())->toBe([['name' => 'GST', 'type' => 'tax', 'value' => '10%']]);
});
test('clearItems empties items but keeps conditions', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->putConditions([['name' => 'GST']]);
$this->driver->clearItems();
expect($this->driver->getItems())->toBe([]);
expect($this->driver->getConditions())->toBe([['name' => 'GST']]);
});
test('flush empties items and conditions', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->putConditions([['name' => 'GST']]);
$this->driver->flush();
expect($this->driver->getItems())->toBe([]);
expect($this->driver->getConditions())->toBe([]);
});
test('setSessionKey updates the key', function () {
$this->driver->setSessionKey('new-key');
expect($this->driver->getSessionKey())->toBe('new-key');
});
test('getSessionModel returns null', function () {
expect($this->driver->getSessionModel())->toBeNull();
});
});
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
describe('SessionDriver', function () {
beforeEach(function () {
$this->session = new SessionMock;
$this->driver = new SessionDriver($this->session, 'test-session');
});
test('returns its session key', function () {
expect($this->driver->getSessionKey())->toBe('test-session');
});
test('returns session as driver name', function () {
expect($this->driver->getDriverName())->toBe('session');
});
test('starts with empty items', function () {
expect($this->driver->getItems())->toBe([]);
});
test('stores and retrieves items', function () {
$items = [['id' => 1, 'name' => 'Widget', 'price' => 10.0]];
$this->driver->putItems($items);
expect($this->driver->getItems())->toBe($items);
});
test('starts with empty conditions', function () {
expect($this->driver->getConditions())->toBe([]);
});
test('stores and retrieves conditions', function () {
$conditions = [['name' => 'GST', 'type' => 'tax', 'value' => '10%']];
$this->driver->putConditions($conditions);
expect($this->driver->getConditions())->toBe($conditions);
});
test('clearItems removes items from session', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->putConditions([['name' => 'GST']]);
$this->driver->clearItems();
expect($this->driver->getItems())->toBe([]);
expect($this->driver->getConditions())->toBe([['name' => 'GST']]);
});
test('flush removes items and conditions from session', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->putConditions([['name' => 'GST']]);
$this->driver->flush();
expect($this->driver->getItems())->toBe([]);
expect($this->driver->getConditions())->toBe([]);
});
test('setSessionKey migrates data to new key', function () {
$this->driver->putItems([['id' => 1]]);
$this->driver->putConditions([['name' => 'GST']]);
$this->driver->setSessionKey('new-key');
expect($this->driver->getSessionKey())->toBe('new-key');
expect($this->driver->getItems())->toBe([['id' => 1]]);
expect($this->driver->getConditions())->toBe([['name' => 'GST']]);
});
test('getSessionModel returns null', function () {
expect($this->driver->getSessionModel())->toBeNull();
});
test('accepts explicit session and key, using them directly', function () {
$session = new SessionMock;
$driver = new SessionDriver($session, 'explicit-key');
$driver->putItems([['id' => 1]]);
expect($driver->getSessionKey())->toBe('explicit-key');
expect($driver->getItems())->toBe([['id' => 1]]);
});
test('setSessionKey on empty cart does not write null to session', function () {
$this->driver->setSessionKey('new-key');
// After key change, empty cart should still return empty arrays, not null
expect($this->driver->getItems())->toBe([]);
expect($this->driver->getConditions())->toBe([]);
// And the session should not have null values written
expect($this->session->get('new-key_cart_items'))->toBeNull();
expect($this->session->get('new-key_cart_conditions'))->toBeNull();
});
});
+2 -2
View File
@@ -2,6 +2,7 @@
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\MockProduct;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
@@ -10,10 +11,9 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
+2 -2
View File
@@ -1,6 +1,7 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
@@ -8,10 +9,9 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMockOtherFormat.php')
);
});
+159
View File
@@ -0,0 +1,159 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
require (__DIR__ . '/../Helpers/ConfigConditionsMock.php')
);
});
afterEach(function () {
Mockery::close();
});
describe('quantity-limited item conditions', function () {
test('condition without applies_to applies to all items', function () {
$discount = new CartCondition([
'name' => '5% Off',
'type' => 'discount',
'value' => '-5%',
]);
$this->cart->add([
'id' => 1,
'name' => 'Widget',
'price' => 50.00,
'quantity' => 2,
'attributes' => [],
'conditions' => $discount,
]);
// Without applies_to: 50*0.95 * 2 = 95
expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(95.00);
expect($this->cart->getSubTotal())->toEqual(95.00);
});
test('condition with applies_to only discounts that many items', function () {
$discount = new CartCondition([
'name' => '5% Off First Item',
'type' => 'discount',
'value' => '-5%',
'applies_to' => 1,
]);
$this->cart->add([
'id' => 1,
'name' => 'Widget',
'price' => 50.00,
'quantity' => 2,
'attributes' => [],
'conditions' => $discount,
]);
// applies_to: 1 → (50*0.95) + 50 = 97.5
expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(97.50);
expect($this->cart->getSubTotal())->toEqual(97.50);
});
test('applies_to greater than or equal to quantity discounts all items', function () {
$discount = new CartCondition([
'name' => '5% Off',
'type' => 'discount',
'value' => '-5%',
'applies_to' => 5,
]);
$this->cart->add([
'id' => 1,
'name' => 'Widget',
'price' => 50.00,
'quantity' => 2,
'attributes' => [],
'conditions' => $discount,
]);
// applies_to 5 >= qty 2, so all items discounted: 50*0.95 * 2 = 95
expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(95.00);
});
test('applies_to of zero means condition does not apply to any item', function () {
$discount = new CartCondition([
'name' => '5% Off',
'type' => 'discount',
'value' => '-5%',
'applies_to' => 0,
]);
$this->cart->add([
'id' => 1,
'name' => 'Widget',
'price' => 50.00,
'quantity' => 2,
'attributes' => [],
'conditions' => $discount,
]);
// applies_to 0: no items discounted → 50 * 2 = 100
expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(100.00);
});
test('multiple conditions where only one has applies_to', function () {
$limitedDiscount = new CartCondition([
'name' => '10% Off First Item',
'type' => 'discount',
'value' => '-10%',
'applies_to' => 1,
]);
$globalDiscount = new CartCondition([
'name' => '5% Off All',
'type' => 'discount',
'value' => '-5%',
]);
$this->cart->add([
'id' => 1,
'name' => 'Widget',
'price' => 100.00,
'quantity' => 2,
'attributes' => [],
'conditions' => [$limitedDiscount, $globalDiscount],
]);
// Item 1: 100 * 0.90 * 0.95 = 85.50
// Item 2: 100 * 0.95 = 95.00 (only global applies)
// Total: 180.50
expect($this->cart->get(1)->getPriceSumWithConditions())->toEqual(180.50);
});
test('getAppliesToQuantity returns null when not set', function () {
$condition = new CartCondition([
'name' => 'Discount',
'type' => 'discount',
'value' => '-5%',
]);
expect($condition->getAppliesToQuantity())->toBeNull();
});
test('getAppliesToQuantity returns the set value', function () {
$condition = new CartCondition([
'name' => 'Discount',
'type' => 'discount',
'value' => '-5%',
'applies_to' => 3,
]);
expect($condition->getAppliesToQuantity())->toBe(3);
});
});
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Shipping\ShippingRate;
use Wearepixel\Cart\Exceptions\InvalidShippingRateException;
class FlatRateShipping extends ShippingRate
{
protected string $name = 'Standard Shipping';
protected string $value = '+10';
public function isApplicable(): bool
{
return true;
}
}
class FreeShipping extends ShippingRate
{
protected string $name = 'Free Shipping';
protected string $value = '+0';
public function isApplicable(): bool
{
return false;
}
}
describe('ShippingRate', function () {
test('converts to a CartCondition', function () {
$condition = (new FlatRateShipping)->toCondition();
expect($condition)->toBeInstanceOf(CartCondition::class);
expect($condition->getName())->toBe('Standard Shipping');
expect($condition->getValue())->toBe('+10');
expect($condition->getType())->toBe('shipping');
expect($condition->getTarget())->toBe('total');
});
test('isApplicable() returning false causes toCondition() to throw', function () {
expect(fn() => (new FreeShipping)->toCondition())
->toThrow(InvalidShippingRateException::class);
});
test('supports subtotal as target', function () {
$rate = new class extends ShippingRate {
protected string $name = 'Express';
protected string $value = '+25';
protected string $target = 'subtotal';
public function isApplicable(): bool { return true; }
};
expect($rate->toCondition()->getTarget())->toBe('subtotal');
});
test('supports maximum free shipping threshold', function () {
$rate = new class extends ShippingRate {
protected string $name = 'Conditional Shipping';
protected string $value = '+15';
protected ?float $maximum = 100.0;
public function isApplicable(): bool { return true; }
};
expect($rate->toCondition()->getMaximum())->toBe(100.0);
});
});
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Tax\TaxRule;
use Wearepixel\Cart\Exceptions\InvalidTaxRuleException;
class AuGst extends TaxRule
{
protected string $name = 'GST';
protected string $value = '10%';
public function isApplicable(): bool
{
return true;
}
}
class NotApplicableTax extends TaxRule
{
protected string $name = 'VAT';
protected string $value = '20%';
public function isApplicable(): bool
{
return false;
}
}
describe('TaxRule', function () {
test('converts to a CartCondition', function () {
$condition = (new AuGst)->toCondition();
expect($condition)->toBeInstanceOf(CartCondition::class);
expect($condition->getName())->toBe('GST');
expect($condition->getValue())->toBe('10%');
expect($condition->getType())->toBe('tax');
expect($condition->getTarget())->toBe('subtotal');
});
test('isApplicable() returning false causes toCondition() to throw', function () {
expect(fn() => (new NotApplicableTax)->toCondition())
->toThrow(InvalidTaxRuleException::class);
});
test('supports total as target', function () {
$tax = new class extends TaxRule {
protected string $name = 'Sales Tax';
protected string $value = '8%';
protected string $target = 'total';
public function isApplicable(): bool { return true; }
};
expect($tax->toCondition()->getTarget())->toBe('total');
});
test('supports minimum threshold', function () {
$tax = new class extends TaxRule {
protected string $name = 'Luxury Tax';
protected string $value = '15%';
protected ?float $minimum = 500.0;
public function isApplicable(): bool { return true; }
};
expect($tax->toCondition()->getMinimum())->toBe(500.0);
});
});
+82
View File
@@ -0,0 +1,82 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Testing\CartFactory;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
function makeCartForFactory(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch');
return new Cart(
new SessionDriver(new SessionMock, 'TESTKEY'),
$events,
'cart',
require __DIR__ . '/../../Helpers/ConfigMock.php',
);
}
beforeEach(function () {
$this->cart = makeCartForFactory();
$this->factory = new CartFactory($this->cart);
});
afterEach(fn() => Mockery::close());
describe('CartFactory', function () {
test('withItems adds N generic items to the cart', function () {
$this->factory->withItems(3);
expect($this->cart->getContent()->count())->toBe(3);
expect($this->cart->getTotalQuantity())->toBe(3);
});
test('withCondition adds a condition to the cart', function () {
$condition = new CartCondition(['name' => 'GST', 'type' => 'tax', 'value' => '10%', 'target' => 'subtotal']);
$this->factory->withCondition($condition);
expect($this->cart->getCondition('GST'))->not->toBeNull();
});
test('methods are chainable', function () {
$result = $this->factory->withItems(2)->withCondition(
new CartCondition(['name' => 'Discount', 'type' => 'discount', 'value' => '-5%', 'target' => 'subtotal'])
);
expect($result)->toBeInstanceOf(CartFactory::class);
expect($this->cart->getContent()->count())->toBe(2);
expect($this->cart->getCondition('Discount'))->not->toBeNull();
});
test('withItems generates unique item IDs', function () {
$this->factory->withItems(3);
$ids = $this->cart->getContent()->keys()->toArray();
expect(array_unique($ids))->toHaveCount(3);
});
test('consecutive withItems calls produce unique IDs', function () {
$this->factory->withItems(2)->withItems(2);
$ids = $this->cart->getContent()->keys()->toArray();
expect(array_unique($ids))->toHaveCount(4);
expect($this->cart->getContent()->count())->toBe(4);
});
test('withCoupon adds a coupon condition to the cart', function () {
$coupon = new class extends \Wearepixel\Cart\Coupons\Coupon {
protected string $code = 'SAVE10';
protected string $value = '-10%';
protected string $target = 'subtotal';
public function isValid(): bool { return true; }
};
$this->factory->withCoupon($coupon);
expect($this->cart->getCondition('SAVE10'))->not->toBeNull();
});
});
+112
View File
@@ -0,0 +1,112 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
function makeFakeableCart(): Cart
{
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch');
return new Cart(
new SessionDriver(new SessionMock, 'TESTKEY'),
$events,
'cart',
require __DIR__ . '/../../Helpers/ConfigMock.php',
);
}
beforeEach(function () {
$this->cart = makeFakeableCart();
});
afterEach(fn() => Mockery::close());
describe('Cart::fake()', function () {
test('returns the CartFactory instance', function () {
expect($this->cart->fake())->toBeInstanceOf(\Wearepixel\Cart\Testing\CartFactory::class);
});
test('swaps driver to NullDriver', function () {
$this->cart->fake();
expect($this->cart->getDriver()->getDriverName())->toBe('null');
});
test('Cart operations work after fake()', function () {
$this->cart->fake();
$this->cart->add(1, 'Widget', 50.00, 2);
expect($this->cart->isEmpty())->toBeFalse();
expect($this->cart->getContent()->count())->toBe(1);
});
test('assertContains passes when item is in cart', function () {
$this->cart->fake();
$this->cart->add(1, 'Widget', 50.00, 1);
expect(fn() => $this->cart->assertContains(1))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertContains fails when item is not in cart', function () {
$this->cart->fake();
expect(fn() => $this->cart->assertContains(99))->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertCount passes with correct count', function () {
$this->cart->fake();
$this->cart->add(1, 'A', 10.00, 1);
$this->cart->add(2, 'B', 20.00, 1);
expect(fn() => $this->cart->assertCount(2))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertTotalQuantity passes with correct quantity', function () {
$this->cart->fake();
$this->cart->add(1, 'A', 10.00, 3);
expect(fn() => $this->cart->assertTotalQuantity(3))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertSubTotal passes with correct subtotal', function () {
$this->cart->fake();
$this->cart->add(1, 'Widget', 50.00, 2);
expect(fn() => $this->cart->assertSubTotal(100.00))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertTotal passes with correct total', function () {
$this->cart->fake();
$this->cart->add(1, 'Widget', 50.00, 1);
expect(fn() => $this->cart->assertTotal(50.00))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertConditionApplied passes when condition exists', function () {
$this->cart->fake();
$this->cart->condition(new CartCondition(['name' => 'GST', 'type' => 'tax', 'value' => '10%', 'target' => 'subtotal']));
expect(fn() => $this->cart->assertConditionApplied('GST'))->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertConditionApplied fails when condition does not exist', function () {
$this->cart->fake();
expect(fn() => $this->cart->assertConditionApplied('GST'))->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertEmpty passes when cart is empty', function () {
$this->cart->fake();
expect(fn() => $this->cart->assertEmpty())->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
test('assertNotEmpty passes when cart has items', function () {
$this->cart->fake();
$this->cart->add(1, 'Widget', 10.00, 1);
expect(fn() => $this->cart->assertNotEmpty())->not->toThrow(\PHPUnit\Framework\AssertionFailedError::class);
});
});