diff --git a/.gitattributes b/.gitattributes
index 8f86fd42..906f3b7c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -12,9 +12,7 @@ behat.yml.dist export-ignore
phpspec.yml.dist export-ignore
README.md export-ignore
CHANGELOG.md export-ignore
-composer-require-checker.json export-ignore
-composer-unused.php export-ignore
+composer-dependency-analyser.php export-ignore
ecs.php export-ignore
phpunit.xml.dist export-ignore
-psalm.xml export-ignore
UPGRADE-0.12.md export-ignore
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 4f085c8f..c08ce471 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -2,6 +2,7 @@ name: "build"
on:
push:
branches:
+ - "1.x"
- "0.*.x"
paths-ignore:
- "**/*.md"
@@ -17,7 +18,7 @@ env:
jobs:
coding-standards:
- name: "Coding Standards (PHP ${{ matrix.php-version }} | Dependency versions: ${{ matrix.dependencies }})"
+ name: "Coding Standards (PHP ${{ matrix.php-version }})"
runs-on: "ubuntu-latest"
@@ -26,12 +27,9 @@ jobs:
php-version:
- "8.1" # Always use the lowest version of PHP since a higher version would create actual syntax errors in lower versions
- dependencies:
- - "highest"
-
steps:
- name: "Checkout"
- uses: "actions/checkout@v4"
+ uses: "actions/checkout@v5"
- name: "Setup PHP, with composer and extensions"
uses: "shivammathur/setup-php@v2"
@@ -43,7 +41,7 @@ jobs:
- name: "Install composer dependencies"
uses: "ramsey/composer-install@v3"
with:
- dependency-versions: "${{ matrix.dependencies }}"
+ dependency-versions: "highest"
- name: "Validate composer"
run: "composer validate --strict"
@@ -54,6 +52,9 @@ jobs:
- name: "Check style"
run: "composer check-style"
+ - name: "Run Rector"
+ run: "vendor/bin/rector process --dry-run"
+
- name: "Lint yaml files"
run: "(cd tests/Application && bin/console lint:yaml ../../src/Resources)"
@@ -61,7 +62,7 @@ jobs:
run: "(cd tests/Application && bin/console lint:twig ../../src/Resources)"
dependency-analysis:
- name: "Dependency Analysis (PHP ${{ matrix.php-version }} | Dependency versions: ${{ matrix.dependencies }} | Symfony: ${{ matrix.symfony }})"
+ name: "Dependency Analysis (PHP ${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }})"
runs-on: "ubuntu-latest"
@@ -69,19 +70,15 @@ jobs:
matrix:
php-version:
- "8.1"
- - "8.2"
+ - "8.3"
dependencies:
- "lowest"
- "highest"
- symfony:
- - "~5.4.0"
- - "~6.4.0"
-
steps:
- name: "Checkout"
- uses: "actions/checkout@v4"
+ uses: "actions/checkout@v5"
- name: "Setup PHP, with composer and extensions"
uses: "shivammathur/setup-php@v2"
@@ -89,26 +86,21 @@ jobs:
coverage: "none"
extensions: "${{ env.PHP_EXTENSIONS }}"
php-version: "${{ matrix.php-version }}"
- tools: "composer-require-checker, composer-unused, flex"
+ tools: "flex, shipmonk/composer-dependency-analyser"
- name: "Remove require-dev section in composer.json"
run: "composer config --unset require-dev"
-
+
- name: "Install composer dependencies"
uses: "ramsey/composer-install@v3"
- env:
- SYMFONY_REQUIRE: "${{ matrix.symfony }}"
with:
dependency-versions: "${{ matrix.dependencies }}"
-
- - name: "Run maglnet/composer-require-checker"
- run: "composer-require-checker check --config-file=$(pwd)/composer-require-checker.json"
- - name: "Run composer-unused/composer-unused"
- run: "composer-unused"
+ - name: "Run composer-dependency-analyser"
+ run: "composer-dependency-analyser"
static-code-analysis:
- name: "Static Code Analysis (PHP ${{ matrix.php-version }} | Dependency versions: ${{ matrix.dependencies }} | Symfony: ${{ matrix.symfony }})"
+ name: "Static Code Analysis (PHP ${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }})"
runs-on: "ubuntu-latest"
@@ -117,18 +109,16 @@ jobs:
php-version:
- "8.1"
- "8.2"
+ - "8.3"
dependencies:
- "lowest"
- "highest"
- symfony:
- - "~5.4.0"
- - "~6.4.0"
steps:
- name: "Checkout"
- uses: "actions/checkout@v4"
+ uses: "actions/checkout@v5"
- name: "Setup PHP, with composer and extensions"
uses: "shivammathur/setup-php@v2"
@@ -138,21 +128,16 @@ jobs:
php-version: "${{ matrix.php-version }}"
tools: "flex"
- - name: "Remove sylius/sylius from composer.json"
- run: "composer remove --dev --no-update --no-plugins --no-scripts sylius/sylius"
-
- name: "Install composer dependencies"
uses: "ramsey/composer-install@v3"
- env:
- SYMFONY_REQUIRE: "${{ matrix.symfony }}"
with:
dependency-versions: "${{ matrix.dependencies }}"
- name: "Static analysis"
- run: "vendor/bin/psalm --php-version=${{ matrix.php-version }}"
+ run: "composer analyse"
unit-tests:
- name: "Unit tests (PHP ${{ matrix.php-version }} | Dependency versions: ${{ matrix.dependencies }} | Symfony: ${{ matrix.symfony }})"
+ name: "Unit tests (PHP ${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }})"
runs-on: "ubuntu-latest"
@@ -161,17 +146,16 @@ jobs:
php-version:
- "8.1"
- "8.2"
+ - "8.3"
dependencies:
+ - "lowest"
- "highest"
- symfony:
- - "~5.4.0"
- - "~6.4.0"
steps:
- name: "Checkout"
- uses: "actions/checkout@v4"
+ uses: "actions/checkout@v5"
- name: "Setup PHP, with composer and extensions"
uses: "shivammathur/setup-php@v2"
@@ -181,21 +165,16 @@ jobs:
php-version: "${{ matrix.php-version }}"
tools: "flex"
- - name: "Remove sylius/sylius from composer.json"
- run: "composer remove --dev --no-update --no-plugins --no-scripts sylius/sylius"
-
- name: "Install composer dependencies"
uses: "ramsey/composer-install@v3"
- env:
- SYMFONY_REQUIRE: "${{ matrix.symfony }}"
with:
dependency-versions: "${{ matrix.dependencies }}"
- - name: "Run phpunit"
- run: "composer phpunit"
+ - name: "Run unit tests"
+ run: "vendor/bin/phpunit --testsuite unit"
- integration-tests:
- name: "Integration tests (PHP ${{ matrix.php-version }} | Dependency versions: ${{ matrix.dependencies }} | Symfony: ${{ matrix.symfony }})"
+ functional-tests:
+ name: "Functional tests (PHP ${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }})"
runs-on: "ubuntu-latest"
@@ -203,20 +182,19 @@ jobs:
matrix:
php-version:
- "8.1"
- - "8.2"
+ - "8.3"
dependencies:
+ - "lowest"
- "highest"
- symfony:
- - "~6.4"
steps:
- name: "Start MySQL"
run: "sudo /etc/init.d/mysql start"
- name: "Checkout"
- uses: "actions/checkout@v4"
+ uses: "actions/checkout@v5"
- name: "Setup PHP, with composer and extensions"
uses: "shivammathur/setup-php@v2"
@@ -224,25 +202,15 @@ jobs:
coverage: "none"
extensions: "${{ env.PHP_EXTENSIONS }}"
php-version: "${{ matrix.php-version }}"
- tools: "flex,symfony"
-
- - name: "Setup node"
- uses: "actions/setup-node@v4"
- with:
- node-version: "18"
+ tools: "flex"
- name: "Install composer dependencies"
uses: "ramsey/composer-install@v3"
- env:
- SYMFONY_REQUIRE: "${{ matrix.symfony }}"
with:
dependency-versions: "${{ matrix.dependencies }}"
- - name: "Install wkhtmltopdf"
- run: "sudo apt-get install wkhtmltopdf"
-
- name: "Lint container"
- run: '(cd tests/Application && bin/console lint:container)'
+ run: "(cd tests/Application && bin/console lint:container)"
- name: "Create database"
run: "(cd tests/Application && bin/console doctrine:database:create)"
@@ -251,81 +219,103 @@ jobs:
run: "(cd tests/Application && bin/console doctrine:schema:create)"
- name: "Validate Doctrine mapping"
- run: "(cd tests/Application && bin/console doctrine:schema:validate)"
+ run: "(cd tests/Application && bin/console doctrine:schema:validate -vvv)" # the verbose flag shows the missing SQL statements, if any
- - name: "Get Yarn cache directory"
- id: "yarn-cache"
- run: "echo \"::set-output name=dir::$(yarn cache dir)\""
+ - name: "Run functional tests"
+ run: "vendor/bin/phpunit --testsuite functional"
- - name: "Cache Yarn"
- uses: "actions/cache@v2"
- with:
- path: "${{ steps.yarn-cache.outputs.dir }}"
- key: "yarn-${{ hashFiles('**/package.json **/yarn.lock') }}"
- restore-keys: "yarn-"
+ end-to-end-tests:
+ name: "End to end tests (PHP ${{ matrix.php-version }})"
- - name: "Install JS dependencies"
- run: "(cd tests/Application && yarn install)"
+ runs-on: "ubuntu-latest"
- - name: "Install assets"
- run: "(cd tests/Application && bin/console assets:install public -vvv)"
+ # The test application is served for real here, so it needs an environment with a normal session
+ # storage rather than the mock one the test environment uses for functional tests
+ env:
+ APP_ENV: "dev"
+ APP_DEBUG: "1"
+ PLAYWRIGHT_BASE_URL: "http://127.0.0.1:8080"
- - name: "Build assets"
- run: "(cd tests/Application && yarn build)"
+ strategy:
+ matrix:
+ php-version:
+ - "8.3"
- - name: "Output PHP version for Symfony CLI"
- run: "php -v | head -n 1 | awk '{ print $2 }' > .php-version"
+ steps:
+ - name: "Start MySQL"
+ run: "sudo /etc/init.d/mysql start"
+
+ - name: "Checkout"
+ uses: "actions/checkout@v5"
- - name: "Install certificates"
- run: "symfony server:ca:install"
+ - name: "Setup PHP, with composer and extensions"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ coverage: "none"
+ extensions: "${{ env.PHP_EXTENSIONS }}"
+ php-version: "${{ matrix.php-version }}"
+ tools: "flex, symfony-cli"
- - name: "Run Chrome Headless"
- run: "google-chrome-stable --enable-automation --disable-background-networking --no-default-browser-check --no-first-run --disable-popup-blocking --disable-default-apps --allow-insecure-localhost --disable-translate --disable-extensions --no-sandbox --enable-features=Metal --headless --remote-debugging-port=9222 --window-size=2880,1800 --proxy-server='direct://' --proxy-bypass-list='*' http://127.0.0.1 > /dev/null 2>&1 &"
+ - name: "Install composer dependencies"
+ uses: "ramsey/composer-install@v3"
+ with:
+ dependency-versions: "highest"
- - name: "Wait for Chrome to start"
+ - name: "Setup Node"
+ uses: "actions/setup-node@v4"
+ with:
+ node-version-file: "tests/Application/.nvmrc"
+
+ - name: "Build assets"
+ run: "(cd tests/Application && yarn install --frozen-lockfile && yarn build)"
+
+ - name: "Create database and load fixtures"
run: |
- until curl -s http://127.0.0.1:9222/json/version | grep "Browser" > /dev/null 2>&1
- do
- sleep 1
- done
+ cd tests/Application
+ bin/console doctrine:database:create
+ bin/console doctrine:schema:create
+ bin/console sylius:fixtures:load -n
+ bin/console assets:install public --symlink --relative
- - name: "Run webserver"
- run: "(cd tests/Application && symfony server:start --port=8080 --dir=public --daemon)"
+ - name: "Serve the test application"
+ run: |
+ cd tests/Application
+ symfony serve --no-tls --daemon --port=8080
+ symfony server:status
- - name: "Wait for webserver to start"
+ - name: "Install Playwright"
run: |
- until symfony server:list | grep /public > /dev/null 2>&1
- do
- sleep 1
- done
+ cd tests/Playwright
+ yarn install --frozen-lockfile
+ npx playwright install chromium --with-deps
- - name: "Run behat"
- run: "vendor/bin/behat --colors --strict -vvv --no-interaction || vendor/bin/behat --colors --strict -vvv --no-interaction --rerun"
+ - name: "Run end to end tests"
+ run: "(cd tests/Playwright && npx playwright test)"
- - name: "Upload Behat logs"
+ - name: "Upload the report"
uses: "actions/upload-artifact@v4"
if: "failure()"
with:
- name: "Behat logs"
- path: "etc/build/"
- if-no-files-found: "ignore"
+ name: "playwright-report"
+ path: "tests/Playwright/playwright-report"
+ retention-days: 7
- code-coverage:
- name: "Code Coverage (PHP ${{ matrix.php-version }} | Dependency versions: ${{ matrix.dependencies }})"
+ mutation-tests:
+ name: "Mutation tests (PHP ${{ matrix.php-version }})"
runs-on: "ubuntu-latest"
strategy:
matrix:
php-version:
- - "8.2"
+ - "8.3"
dependencies:
- "highest"
steps:
- name: "Checkout"
- uses: "actions/checkout@v4"
+ uses: "actions/checkout@v5"
- name: "Setup PHP, with composer and extensions"
uses: "shivammathur/setup-php@v2"
@@ -339,10 +329,52 @@ jobs:
with:
dependency-versions: "${{ matrix.dependencies }}"
+ # Scoped to the unit suite: infection runs the test suite itself, and the functional one needs a
+ # booted kernel and a database that this job deliberately does not provision
+ - name: "Run infection"
+ run: "vendor/bin/infection --threads=max --test-framework-options=\"--testsuite=unit\""
+ env:
+ STRYKER_DASHBOARD_API_KEY: "${{ secrets.STRYKER_DASHBOARD_API_KEY }}"
+
+ code-coverage:
+ name: "Code Coverage (PHP ${{ matrix.php-version }})"
+
+ runs-on: "ubuntu-latest"
+
+ strategy:
+ matrix:
+ php-version:
+ - "8.3"
+
+ steps:
+ - name: "Start MySQL"
+ run: "sudo /etc/init.d/mysql start"
+
+ - name: "Checkout"
+ uses: "actions/checkout@v5"
+
+ - name: "Setup PHP, with composer and extensions"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ coverage: "pcov"
+ extensions: "${{ env.PHP_EXTENSIONS }}"
+ php-version: "${{ matrix.php-version }}"
+
+ - name: "Install composer dependencies"
+ uses: "ramsey/composer-install@v3"
+ with:
+ dependency-versions: "highest"
+
+ - name: "Create database"
+ run: "(cd tests/Application && bin/console doctrine:database:create)"
+
+ - name: "Create database schema"
+ run: "(cd tests/Application && bin/console doctrine:schema:create)"
+
- name: "Collect code coverage with pcov and phpunit/phpunit"
run: "vendor/bin/phpunit --coverage-clover=.build/logs/clover.xml"
- name: "Send code coverage report to Codecov.io"
+ uses: "codecov/codecov-action@v5"
env:
CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}"
- run: "bash <(curl -s https://codecov.io/bash)"
diff --git a/.gitignore b/.gitignore
index 82b272e5..d1f8d4d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,12 +2,16 @@
/node_modules/
/composer.lock
-/etc/build/*
-!/etc/build/.gitignore
+/.build/
+/.phpunit.result.cache
+.php-version
/tests/Application/yarn.lock
+/.playwright-mcp/
+/tests/Application/public/build/
-/behat.yml
-/phpspec.yml
-/.phpunit.result.cache
-.php-version
+# The Playwright lock file is committed on purpose, so CI can install with --frozen-lockfile
+/tests/Playwright/node_modules/
+/tests/Playwright/test-results/
+/tests/Playwright/playwright-report/
+/tests/Playwright/.auth/
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000..37cce59c
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,12 @@
+{
+ "mcpServers": {
+ "playwright": {
+ "type": "stdio",
+ "command": "npx",
+ "args": [
+ "@playwright/mcp@latest"
+ ],
+ "env": {}
+ }
+ }
+}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 17d78a86..9baf43dc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,55 +4,80 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
-`setono/sylius-gift-card-plugin` — a Sylius plugin adding gift card functionality (buy gift cards, spend them on orders, check balances, admin management, API Platform support). PHP >= 8.1, Symfony 5.4 || 6.4, Sylius ~1.12. Development branches follow the `0.*.x` naming scheme (current: `0.12.x`).
+`setono/sylius-gift-card-plugin` — a Sylius plugin adding gift card functionality. Version 1.x (branch `1.x`) is a full rewrite targeting Sylius 1.13/1.14, PHP >= 8.1, Symfony ^6.4. See `REWRITE.md` for the complete architecture plan, decisions, and progress log of the rewrite.
+
+Key feature set: customers buy gift cards choosing the amount themselves (virtual = email delivery, physical = shipped + design chosen by the customer); redeeming a gift card creates a real Payment entity against the order, leaving the order total intact — a gift card settles a liability the shop already took money for, so it is a means of payment rather than a discount. No API layer.
## Commands
```bash
-composer analyse # Psalm static analysis (psalm.xml, baseline in psalm-baseline.xml)
-composer check-style # ECS check (sylius-labs coding standard, config in ecs.php)
+composer analyse # PHPStan at max level (phpstan.neon)
+composer check-style # ECS check (ecs.php)
composer fix-style # ECS auto-fix
-composer phpunit # PHPUnit test suite (tests/Unit)
+composer phpunit # full PHPUnit suite
+vendor/bin/phpunit --testsuite unit # unit tests only (no database needed)
+vendor/bin/phpunit --testsuite functional # functional tests (require MySQL, see below)
vendor/bin/phpunit tests/Unit/Path/To/SomeTest.php # single test file
-vendor/bin/phpunit --filter testMethodName # single test method
-vendor/bin/phpspec run # phpspec specs (spec/ directory)
-vendor/bin/behat # Behat acceptance tests (requires running test app, see below)
+vendor/bin/phpunit --filter testMethodName # single test method
+vendor/bin/rector --dry-run # rector check (CI runs this)
```
-Note: the README mentions `composer tests`, `composer try` and `composer all` — those scripts do not exist in composer.json; use the commands above.
+## Test application
-### Test application
+`tests/Application/` contains a full Sylius app used as the kernel for PHPUnit (bootstrap `tests/Application/config/bootstrap.php`) and for manual/browser verification. Run its console with `(cd tests/Application && bin/console ...)`. It needs a MySQL database (`DATABASE_URL` in `tests/Application/.env`) and built assets (`yarn install && yarn build` inside tests/Application). Admin credentials after fixtures: username `sylius`, password `sylius`.
-`tests/Application/` contains a full Sylius app used as the kernel for PHPUnit (bootstrap `tests/Application/config/bootstrap.php`) and Behat. Run its console with `(cd tests/Application && bin/console ...)`. Behat (behat.yml.dist) expects the app served at `http://localhost:8080`, a MySQL database, built assets (`yarn install && yarn build` inside tests/Application), and headless Chrome for `@javascript` scenarios — see the `integration-tests` job in `.github/workflows/build.yaml` for the full setup sequence.
+**Always run the web server with `symfony serve`** (e.g. `(cd tests/Application && symfony serve -d --port=8080)`) — never `php -S` / `symfony php -S ... router.php`. `symfony serve` handles routing itself (no `router.php` needed) and serves from `public/` automatically.
-CI additionally runs `composer validate --strict`, `composer normalize --dry-run`, `composer-require-checker`, `composer-unused`, yaml/twig/container lints, and Doctrine schema validation.
+**Assets: use Node 20** (`tests/Application/.nvmrc` pins it — run `nvm use` before `yarn install`/`yarn build`). The frontend uses `@sylius-ui/frontend` (Dart Sass); Node 22+ breaks the build. `package.json` also pins `jquery` via `resolutions` so the admin JS (`jquery.dirtyforms`) loads — without it the admin console throws `jQuery.dirtyForms is not a function` and JS-driven form submits fail.
-## Architecture
+## Testing conventions
+
+- Unit tests live in `tests/Unit`, functional tests (KernelTestCase/WebTestCase booting the test app) in `tests/Functional`.
+- Use a BDD-style naming convention for test methods (`it_does_something`) with the `@test` annotation or `test` prefix.
+- Use Prophecy for mocking (phpspec/prophecy-phpunit), not PHPUnit mock objects.
+- Form type tests extend `Symfony\Component\Form\Test\TypeTestCase`.
+- Form types should bind to a `data_class` rather than produce an array — so the rules travel with the data instead of with the one form that happens to produce it, and consumers get a typed object instead of an array shape annotation. See `AdjustGiftCardBalanceCommand` / `AddGiftCardToOrderCommand`.
+- Validation constraints are declared in XML under `src/Resources/config/validation/`, one file per class, never as PHP attributes — models and command objects alike. Host applications override plugin validation by pointing at these files, which only works if every constraint lives there.
-Namespace `Setono\SyliusGiftCardPlugin\` maps to `src/`; tests are `Setono\SyliusGiftCardPlugin\Tests\` in `tests/`. Bundle class is `src/SetonoSyliusGiftCardPlugin.php`; services are XML files under `src/Resources/config/services/` (one per area: applicator, factory, order_processor, ...) imported by `services.xml`. Resource/grid/route/serializer/API configs also live under `src/Resources/config/`. The bundle must be registered before SyliusGridBundle in host apps (parameter resolution order).
+## UI testing with Playwright
-### Domain model
+UI MUST be covered by **Playwright tests**, not just looked at once. Ad-hoc checking only ever exercises the page you happened to change, so regressions on every other page go unnoticed — a dropped option in the test app's `_details.html.twig` override left *every* non-simple product's admin edit page returning a 500, and manual verification of the gift card pages never touched it.
-`GiftCard` holds a code, `amount`/`initialAmount` (integers, minor units per Sylius money convention), currency, channel, optional customer/expiry, and an enabled toggle. When bought in the shop it is linked 1:1 to an `OrderItemUnit`. `GiftCardConfiguration` (+ `GiftCardChannelConfiguration` join entity) defines per-channel/locale settings: PDF template, default validity period, images.
+The suite lives in `tests/Playwright` and runs against a served `tests/Application`:
-Host applications integrate by applying the plugin's traits/interfaces to their entities — `ProductTrait` (adds `isGiftCard` and `giftCardAmountConfigurable` flags), `OrderTrait` (applied gift cards collection), `OrderItemTrait`, `OrderItemUnitTrait` (gift card relation), and repository traits in `src/Doctrine/ORM/`. The README documents the exact setup; `tests/Application/` shows a working example.
+```bash
+(cd tests/Application && symfony serve -d --port=8080) # serve the app first
+cd tests/Playwright && yarn install && npx playwright install chromium
+npx playwright test # whole suite
+npx playwright test --project=admin # admin specs only
+npx playwright test --headed -g 'cart' # watch a single test
+```
-### Two distinct gift card flows
+`PLAYWRIGHT_BASE_URL` overrides the default `https://127.0.0.1:8080`. The admin specs share a signed-in session created by `specs/auth.setup.js`; the shop specs run anonymously. Specs must **discover their subjects** (grid links, locale switcher) rather than hardcode ids, codes or locales, so they keep working against a freshly seeded database.
-**Buying a gift card** (product flagged as gift card): the gift card entity is created at add-to-cart time via `GiftCardFactory::createFromOrderItemUnitAndCart()` — called from `Form/Extension/AddToCartTypeExtension` (shop form flow, POST_SUBMIT listener) and `Api/CommandHandler/AddItemToCartHandler` (API flow). The card starts disabled; winzou state machine callbacks (`src/Resources/config/state_machine/`) drive its lifecycle through `Operator/OrderGiftCardOperator`: checkout complete → `associateToCustomer`, payment paid → `enable` + `send` (email with PDF), order cancel → `disable`. "Configurable" gift card products let the customer choose the amount.
+Any new UI needs a spec here. Coverage today: admin gift cards index/show/edit, designs index/edit, balance report, gift card and design preview PDFs, product edit for simple/configurable/gift card products, and the shop gift card product page, locales, add-to-cart and redemption.
-**Spending a gift card**: `Applicator/GiftCardApplicator` validates (enabled, not expired, channel matches) and attaches the card to the order, then reprocesses it. `OrderProcessor/OrderGiftCardProcessor` (a Sylius order processor) converts each applied card into a negative order adjustment (`AdjustmentInterface::ORDER_GIFT_CARD_ADJUSTMENT`, origin code = gift card code) capped at the eligible order total. Actual balance mutation happens via state machine callbacks on `Modifier/OrderGiftCardAmountModifier`: decrement on order create, increment back on cancel. Both flows can coexist on one order.
+Redemption does not change what the order costs — the gift card becomes a payment against it — so specs assert the "Remaining to pay" figure rather than expecting the order total to drop.
+
+When a test app template overrides a Sylius one, diff it against the original in `vendor/sylius/sylius/.../Resources/views/` before trusting it; the override silently drifts as Sylius changes, and options dropped from a `form_row` call fail only at render time.
+
+Use the Playwright MCP tools (configured in `.mcp.json`) while developing a change, but land the coverage as a spec.
+
+## Architecture
-### API
+Namespace `Setono\SyliusGiftCardPlugin\` maps to `src/`; tests are `Setono\SyliusGiftCardPlugin\Tests\` in `tests/`. Bundle class `src/SetonoSyliusGiftCardPlugin.php`; services are XML files under `src/Resources/config/services/` imported by `services.xml`. The DI extension prepends configuration for other bundles (winzou state machine, sylius_ui, sylius_grid, liip_imagine, sylius_mailer) as PHP arrays built in `prepend()` — host apps do not import plugin config manually. Register the bundle before SyliusGridBundle.
-API Platform resources are declared in `src/Resources/config/api_resources/` with messenger commands/handlers in `src/Api/Command` and `src/Api/CommandHandler`. The plugin replaces the input of Sylius' shop `add item to cart` operation with its own `AddItemToCart` command (carrying gift card info); host apps must copy/adjust `Order.xml` as described in the README.
+State machine callbacks are registered twice, once as winzou callbacks in `prepend()` and once as Symfony Workflow subscribers in `src/EventSubscriber/Workflow/`, so the plugin works under either adapter. Keep the two in sync when changing them.
-### PDF rendering
+Anything reacting to a Symfony event is an **event subscriber**, so the event name and priority live in `getSubscribedEvents()` next to the code rather than in a service tag. `kernel.event_listener` is only used where a subscriber is impossible — currently Sylius' own `ImagesUploadListener`, which we cannot add the interface to. Doctrine's `onFlush` listener is a Doctrine event, not a Symfony one, and stays a listener.
-Gift cards render to PDF via knp-snappy/wkhtmltopdf (`Renderer/PdfRenderer`), with template content and rendering options coming from the gift card's configuration (`Provider/`). Admin supports live preview of example PDFs.
+Only doctrine/orm is supported. Resources: `gift_card`, `gift_card_design` (translatable, images with front|back types), `gift_card_transaction` (append-only balance ledger, written only by the balance operator).
-### Test layout
+### Domain rules
-- `tests/Unit/` — PHPUnit (includes DI/config tests using matthiasnoback's symfony-config-test / dependency-injection-test)
-- `spec/` — phpspec, mirrors `src/` structure
-- `features/` + `tests/Behat/` — Behat contexts/pages built on the Sylius Behat pack
+- `GiftCard.amount`/`initialAmount` are integers in minor units (Sylius money convention). `initialAmount` is set explicitly — no implicit seeding.
+- A disabled, "pending" GiftCard is created at add-to-cart (one per OrderItemUnit) carrying amount/message/design/deliveryType; a reconciliation pass at checkout complete creates cards for quantity-bumped units, removes stale ones, and re-snapshots final amounts from unit totals. Cards are enabled on payment and emailed (all delivery types); disabled on order cancel.
+- `deliveryType` (virtual|physical) is derived from `variant->isShippingRequired()` — never from product structure assumptions.
+- Balance mutations go through the balance operator exclusively, which writes `GiftCardTransaction` ledger rows (idempotency via nullable-unique `idempotencyKey`). Nothing below controllers flushes.
+- Redeeming a gift card creates one Payment per card against the order (offline gateway payment method, lazily created), leaving the order total intact. Balance is committed at order placement, restored on cancel/refund.
+- Gift cards cannot pay for gift-card line items (EligibleTotalCalculator default).
diff --git a/README.md b/README.md
index 89ed61ab..b1c1c4b7 100644
--- a/README.md
+++ b/README.md
@@ -4,78 +4,64 @@
[![Software License][ico-license]](LICENSE)
[![Build Status][ico-github-actions]][link-github-actions]
-Add gift card functionality to your store:
+Add gift card functionality to your Sylius store:
-- Buy gift cards
-- Use gift cards to purchase products
-- See the balance of a gift card by looking up the gift card code
+- **Buy gift cards** — customers choose the amount, a design and an optional message, and pick whether the gift card is **virtual** (delivered by email as a PDF) or **physical** (shipped like a normal product).
+- **Redeem gift cards** — customers apply a gift card code in the cart, and it becomes a **real payment** against the order rather than a discount on it.
+- **Admin management** — a gift card grid, gift card designs, a one-click "create gift card product" scaffold, manual balance adjustments (with an audit ledger), and an outstanding-balance dashboard.
-The administrator will have access to a dashboard showing the total outstanding balance of gift cards which
-can be used for auditing.
+> This is the `1.x` line for **Sylius 1.13 / 1.14**. It is a ground-up rewrite of the `0.12.x` plugin. There is **no API layer** in 1.x — see [`UPGRADE-1.0.md`](UPGRADE-1.0.md) if you are coming from `0.12.x`.
-## Screenshots
+## Table of contents
-▶▶ [Skip screenshots and go to installation](#Installation)
+- [How it works](#how-it-works)
+- [Requirements](#requirements)
+- [Installation](#installation)
+- [Configuration](#configuration)
+- [Customization](#customization)
+- [Development](#development)
+- [License](#license)
-
+## How it works
-
+### Virtual vs physical
-## Api platform support
+Whether a gift card is virtual or physical is derived from the chosen product variant's `shipping required` flag — there is no special product type. The recommended setup is a single gift card product with a "delivery" product option producing a non-shippable *Virtual* variant and a shippable *Physical* variant. Virtual-only stores work too: just create a single non-shippable variant and the delivery selector disappears. Use the **Create gift card product** button in the admin gift card list to scaffold this in one click.
-Everything related to Gift Card can be done via API. Whether it is admin or shop actions
+### Buying a gift card
-## Installation
+The customer chooses the amount, a design and an optional message on the product page (with a live preview). A disabled gift card is created per order item unit at add-to-cart time; at checkout completion it is reconciled against the final amounts, and when the order is paid it is enabled and emailed (with a PDF attachment) to the customer.
-### Require plugin with composer:
+### Redeeming a gift card
-```bash
-$ composer require setono/sylius-gift-card-plugin
-```
+The customer enters a gift card code in the cart. The order total stays intact and each applied gift card becomes a completed [`Payment`](https://docs.sylius.com/the-book/carts-and-orders/payments) using a lazily-created *offline* gift card payment method; the remainder is charged through the normal gateway, and the payment step is skipped automatically when gift cards cover the whole order.
-### Import configuration:
+A gift card is treated as a means of payment rather than a discount, because that is what it is: selling one takes money for a liability the shop settles later, so redeeming it settles that liability instead of reducing what the order is worth. It also keeps gift cards out of the way of promotions, and matches what order management and accounting systems expect to receive.
-```yaml
-# config/packages/setono_sylius_gift_card.yaml
-imports:
- # ...
- - { resource: "@SetonoSyliusGiftCardPlugin/Resources/config/app/config.yaml" }
-```
+Gift cards cannot be used to buy other gift cards, balances are committed when the order is placed and restored when it is cancelled/refunded, and every balance change is recorded in an append-only ledger.
-### (Optional) Import fixtures
+## Requirements
-If you wish to have some gift cards to play with in your application during development.
+| Requirement | Version |
+|-------------|--------------------------------------------|
+| PHP | >= 8.1 |
+| Sylius | 1.13 / 1.14 |
+| Symfony | ^6.4 |
+| ORM | doctrine/orm (the only supported driver) |
-```yaml
-# config/packages/setono_sylius_gift_card.yaml
-imports:
- # ...
- - { resource: "@SetonoSyliusGiftCardPlugin/Resources/config/app/fixtures.yaml" }
-```
+## Installation
-### Import routing:
-
-```yaml
-# config/routes.yaml
-setono_sylius_gift_card:
- resource: "@SetonoSyliusGiftCardPlugin/Resources/config/routes.yaml"
-```
+### Require the plugin with composer
-or if your app doesn't use locales:
-
-```yaml
-# config/routes.yaml
-setono_sylius_gift_card:
- resource: "@SetonoSyliusGiftCardPlugin/Resources/config/routes_no_locale.yaml"
+```bash
+composer require setono/sylius-gift-card-plugin
```
-### Add plugin class to your `bundles.php`:
+### Register the plugin
-Make sure you add it before `SyliusGridBundle`, otherwise you'll get
-`You have requested a non-existent parameter "setono_sylius_gift_card.model.gift_card.class".` exception.
+Add it to `config/bundles.php` **before** `SyliusGridBundle`:
```php
- ['all' => true],
@@ -84,286 +70,166 @@ $bundles = [
];
```
-### Copy templates
+The plugin auto-configures the state machine, grids, UI events, email templates and image filters for you — you do **not** need to import any bundle configuration manually.
-You will find the templates you need to override in the [test application](https://github.com/Setono/SyliusGiftCardPlugin/tree/master/tests/Application/templates).
+Both state machine adapters Sylius supports are covered: the plugin registers winzou callbacks *and* the
+equivalent Symfony Workflow subscribers, so it behaves the same whichever adapter
+`sylius_core.state_machine.default_adapter` is set to. Only the adapter actually applying a transition emits
+its events, so the work is never done twice.
-### Extend entities
+### Import routing
-**Extend `Product`**
-```php
-__giftCardTraitConstruct();
-
parent::__construct();
}
}
```
-**Extend `OrderItem`**
-
```php
-
-