Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
2836b67
Tighten Sylius requirement to ^1.14, describe the plugin, and unset I…
loevgaard Jul 6, 2026
28c6ea1
Split PHPUnit into unit and functional suites with dama/doctrine-test…
loevgaard Jul 6, 2026
ddda81f
Add a Playwright end-to-end test harness running against the test app…
loevgaard Jul 6, 2026
ee54330
Convert the bundle to a Sylius resource bundle with the plugin's real…
loevgaard Jul 6, 2026
3552850
Rewrite CLAUDE.md to describe the loyalty plugin and its actual tooling
loevgaard Jul 6, 2026
e68f48f
Add LoyaltyAccount and LoyaltyProgram models with Doctrine mappings
loevgaard Jul 6, 2026
d86c8aa
Add the append-only ledger: LoyaltyTransaction STI hierarchy with ide…
loevgaard Jul 6, 2026
415cb22
Add EarningRule with condition collection and DryRunResult models
loevgaard Jul 6, 2026
8f99cef
Add repositories and lazy-creating providers for accounts and programs
loevgaard Jul 6, 2026
aaa2e03
Add the domain exception hierarchy and ledger event pairs
loevgaard Jul 6, 2026
d3bcd08
Add the LotReplayer: derived FIFO lot accounting by deterministic replay
loevgaard Jul 6, 2026
43065f6
Add the LoyaltyLedger: the single locked write path into the ledger
loevgaard Jul 6, 2026
9a4560d
Fix static analysis findings in the ledger test
loevgaard Jul 6, 2026
88ec7bc
Add earning rule condition checkers and amount calculators with tagge…
loevgaard Jul 6, 2026
1434f1c
Add the EarningRuleEvaluator with exclusive claiming, stacking, and m…
loevgaard Jul 6, 2026
a4a4412
Add the expression sandbox: typed catalog, validator, evaluator, and …
loevgaard Jul 6, 2026
5a373ee
Add the event-based earning trigger contract with built-in triggers
loevgaard Jul 6, 2026
73e9d85
Add the order earning pipeline: basis calculation, Messenger message,…
loevgaard Jul 6, 2026
cfa4476
Fix code style in the past-order claim handler
loevgaard Jul 6, 2026
e95d248
Add the first functional ledger tests against MySQL
loevgaard Jul 6, 2026
148d746
Add checkout redemption: order trait, distributed adjustment processo…
loevgaard Jul 6, 2026
7bc71a2
Add the cart redemption widget with preset steps and a dominant Use m…
loevgaard Jul 6, 2026
4d1c868
Add expiry, clawback, GDPR handling, and the operational command suite
loevgaard Jul 6, 2026
35203c0
Add the loyalty admin: menu, dashboard, grids, ledger inspector, manu…
loevgaard Jul 6, 2026
50dc94d
Add the loyalty block on the admin customer page and move Twig helper…
loevgaard Jul 6, 2026
3310a98
Add the CodeMirror expression editor infrastructure: form type, lint …
loevgaard Jul 6, 2026
94feda4
Wire expression mode into the rule form and rename all templates to s…
loevgaard Jul 6, 2026
9fe66b9
Add the My loyalty shop dashboard with running-balance transaction hi…
loevgaard Jul 6, 2026
be2e7da
Add public fixture factories and the deterministic loyalty fixture suite
loevgaard Jul 6, 2026
c092f09
Fix fixture factory option parameter variance for static analysis
loevgaard Jul 6, 2026
2ab4baa
Add the Phase 1 functional acceptance tests: double dispatch and conc…
loevgaard Jul 6, 2026
bf2f453
Add the Phase 1 Playwright suite and make winzou-referenced listeners…
loevgaard Jul 6, 2026
0a25538
Add the async-transport awarding test and rewrite the README for the …
loevgaard Jul 6, 2026
717ac93
Add machine-translated locale files for 15 languages and the Phase 1 …
loevgaard Jul 6, 2026
91fea09
Add the tier core: translatable resource, qualification bases, evalua…
loevgaard Jul 6, 2026
47903c4
Add the customer_loyalty_tier promotion rule checker
loevgaard Jul 6, 2026
6e9917e
Add the tier admin: CRUD, program tier settings, and the Phase 2 dash…
loevgaard Jul 6, 2026
12b503d
Add the shop tier UI and the variant-aware earn hints
loevgaard Jul 6, 2026
00c7362
Fix inline tier evaluation and add tier fixtures with an upgrade regr…
loevgaard Jul 6, 2026
fe9ac93
Add the custom tier qualification basis recipe and the Phase 2 cron e…
loevgaard Jul 6, 2026
a05798b
Add Phase 3 referrals: attribution, qualification, fraud checks, rewa…
loevgaard Jul 6, 2026
09e0361
Fix the clawback guard fallout: repository types and unit test doubles
loevgaard Jul 6, 2026
347a994
Document phases 2 and 3 and add the referral e2e spec
loevgaard Jul 6, 2026
64350b7
Pin mutation thresholds at the measured baseline and add the fraud-ch…
loevgaard Jul 6, 2026
c6f8afb
Bring all 15 locales to key parity with the Phase 2 and 3 English str…
loevgaard Jul 6, 2026
955128a
Update the license year
loevgaard Jul 6, 2026
8d7605e
Act on the review TODOs across the codebase
loevgaard Jul 7, 2026
5739e6b
Fix the CI failures: grid 500s, CodeMirror class wipe, vendored edito…
loevgaard Jul 7, 2026
17aa599
Fix the remaining CI failures: missing assets in e2e, translation flo…
loevgaard Jul 7, 2026
886665a
Fix the third round of CI failures: normalize ordering, lowest-deps f…
loevgaard Jul 7, 2026
39b15ac
Make the cart redemption spec price-independent with a fixed quantity
loevgaard Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
72 changes: 70 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ jobs:
dependency-versions: "${{ matrix.dependencies }}"

- name: "Run phpunit"
run: "composer phpunit"
run: "vendor/bin/phpunit --testsuite unit"

integration-tests:
name: "Integration tests (PHP${{ matrix.php-version }} | Deps: ${{ matrix.dependencies }} | SF${{ matrix.symfony }})"
Expand Down Expand Up @@ -247,7 +247,75 @@ jobs:

- name: "Validate Doctrine mapping"
run: "(cd tests/Application && bin/console doctrine:schema:validate -vvv)" # The verbose flag will show 'missing' SQL statements, if any

- name: "Load fixtures"
run: "(cd tests/Application && bin/console sylius:fixtures:load default -n && bin/console sylius:fixtures:load loyalty -n)"

- name: "Run functional tests"
run: "vendor/bin/phpunit --testsuite functional"

e2e-tests:
name: "End-to-end tests (Playwright)"

runs-on: "ubuntu-latest"

strategy:
matrix:
php-version:
- "8.3"

dependencies:
- "highest"

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: "none"
extensions: "${{ env.PHP_EXTENSIONS }}"
php-version: "${{ matrix.php-version }}"

- name: "Install composer dependencies"
uses: "ramsey/composer-install@v3"
with:
dependency-versions: "${{ matrix.dependencies }}"

- name: "Setup node"
uses: "actions/setup-node@v4"
with:
node-version-file: "tests/Application/.nvmrc"

- name: "Build test application assets"
run: "(cd tests/Application && yarn install && yarn build && APP_ENV=test bin/console assets:install public)"

- name: "Create database and schema"
run: "(cd tests/Application && APP_ENV=test bin/console doctrine:database:create -n && APP_ENV=test bin/console doctrine:schema:create -n)"

- name: "Load fixtures"
run: "(cd tests/Application && APP_ENV=test bin/console sylius:fixtures:load default -n && APP_ENV=test bin/console sylius:fixtures:load loyalty -n)"

- name: "Install Playwright"
run: "(cd e2e && npm ci && npx playwright install --with-deps chromium)"

- name: "Run Playwright tests"
run: "(cd e2e && npx playwright test)"

- name: "Upload Playwright report"
uses: "actions/upload-artifact@v4"
if: "failure()"
with:
name: "playwright-report"
path: |
e2e/playwright-report
e2e/test-results
retention-days: 7

mutation-tests:
name: "Mutation tests"

Expand Down Expand Up @@ -312,7 +380,7 @@ jobs:
dependency-versions: "${{ matrix.dependencies }}"

- name: "Collect code coverage with pcov and phpunit/phpunit"
run: "vendor/bin/phpunit --coverage-clover=.build/logs/clover.xml"
run: "vendor/bin/phpunit --testsuite unit --coverage-clover=.build/logs/clover.xml"

- name: "Send code coverage report to Codecov.io"
uses: "codecov/codecov-action@v4"
Expand Down
128 changes: 77 additions & 51 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

TODO
`setono/sylius-loyalty-plugin` is a loyalty plugin for Sylius 1.14 (PHP >= 8.1, Symfony ^6.4, Doctrine ORM, MySQL 8). It provides:

- **Points** (Phase 1): event-driven earning on order/payment lifecycle and customer actions, redemption at checkout as a distributed discount adjustment, expiration, and clawback on cancellation/refund — all backed by a strictly **append-only ledger**.
- **Tiers** (Phase 2): admin-created tiers with configurable qualification bases, earning multipliers (applied after all rules, before rounding), a tier-gated promotion rule checker (`customer_loyalty_tier`), shop progress indicators with a top-tier celebration state, and variant-aware earn hints on product and cart pages (never-overstate filtering via condition evaluability metadata).
- **Referrals** (Phase 3): lazy Crockford-base32 codes, `/r/{code}` landing + `?ref=` query-param attribution (30-day cookie, last click wins), pending referral at registration, first-post-attribution-order qualification at the award moment, extensible fraud checks, dual rewards via `earn_referral` (idempotent per account+referral), dual clawback on cancel/refund of the qualifying order.

Guiding principles: points are a financial liability, so the ledger is the source of truth and balances are derived caches; all earning is idempotent by construction (DB-level unique constraints); everything is channel-aware; every meaningful action dispatches an event; extension points are tagged services or config-registered event classes. B2C only — no cashback, badges/challenges/leaderboards, B2B accounts, emails, or API Platform endpoints.

## Domain Glossary

- **Loyalty account**: one per (customer, channel). Holds a cached `balance` and `lifetimeEarned` — both derived from the ledger, never hand-edited.
- **Ledger**: the append-only list of `LoyaltyTransaction` rows (Doctrine single-table inheritance). Rows are never updated or deleted; corrections are new compensating entries.
- **Lot**: a credit transaction with an optional `expiresAt`. There is no stored "remaining" column — per-lot remainders are **derived** by replay.
- **Replay**: deterministic processing of an account's ledger in `occurredAt ASC, id ASC` order; debits consume open lots FIFO in consumption order `expiresAt ASC NULLS LAST, occurredAt ASC, id ASC`.
- **Clawback**: a debit written when an order that earned points is cancelled/refunded, referencing the original earn credit.
- **Requested vs applied points**: `order.loyaltyPointsRequested` is the customer's persisted intent; on every order recalculation the applied amount is derived as `min(requested, balance, cap)` clamped to a clean multiple of the conversion ratio. The checkout debit is always the **applied** amount.
- **Dry run**: an earning rule mode that evaluates against live traffic and logs would-be awards to an audit table instead of writing ledger entries.

## Code Standards

Expand All @@ -16,54 +32,74 @@ Follow clean code principles and SOLID design patterns when working with this co
- Favor composition over inheritance
- Write code that is easy to test and extend

Plugin-specific conventions:
- **Services** are declared in XML under `src/Resources/config/services/`, loaded from `services.xml`. Service ids are FQCNs; every service with an interface gets an interface **alias**, and consumers are wired against the interface id so projects can swap implementations by re-aliasing. No autowiring inside the plugin — explicit `<argument type="service" id="…"/>` wiring.
- **API surface**: all classes `final` except entities; interfaces are the only supported extension contracts; anything not documented as an extension point is `@internal`.
- **Models** live in `src/Model/` as interface + default implementation, registered under the `setono_sylius_loyalty.resources` config node; Doctrine mappings are XML in `src/Resources/config/doctrine/model/`. Only Doctrine ORM is supported.
- **Database tables** are prefixed `setono_sylius_loyalty__`.
- **No Doctrine migrations ship** — the XML mappings are the schema's source of truth; host projects generate their own via `doctrine:migrations:diff`.
- All ledger writes go through the `LoyaltyLedger` service (pessimistic locking via Doctrine's `LockMode::PESSIMISTIC_WRITE` inside `wrapInTransaction()`); never write transaction rows directly.

### Testing Requirements
- Write unit tests for all new functionality (if it makes sense)
- Follow the BDD-style naming convention for test methods (e.g., `it_should_do_something_when_condition_is_met`)

Policy: **all code is unit tested; behavior that touches the framework or database is additionally functionally tested; everything with a UI is covered by Playwright.** The levels complement — never substitute for — each other.

- **Unit tests** (`tests/Unit/`, plus `tests/DependencyInjection/`): baseline for every class. BDD-style method names (e.g. `it_awards_points_once_for_a_paid_order`).
- **MUST use Prophecy for mocking** - Use the `ProphecyTrait` and `$this->prophesize()` for all mocks, NOT PHPUnit's `$this->createMock()`
- **Form testing** - Use Symfony's best practices for form testing as documented at https://symfony.com/doc/current/form/unit_testing.html
- Extend `Symfony\Component\Form\Test\TypeTestCase` for form type tests
- Use `$this->factory->create()` to create form instances
- Test form submission, validation, and data transformation
- Ensure tests are isolated and don't depend on external state
- Test both happy path and edge cases
- **Form testing** - Extend `Symfony\Component\Form\Test\TypeTestCase`, use `$this->factory->create()`, test submission, validation, and data transformation (see https://symfony.com/doc/current/form/unit_testing.html)
- **Functional tests** (`tests/Functional/`): run against the Sylius test app in `tests/Application/` with MySQL. Extend `Setono\SyliusLoyaltyPlugin\Tests\Functional\FunctionalTestCase`; database state is isolated per test by dama/doctrine-test-bundle. Tests that need real commits (cross-process locking/concurrency) opt out via `StaticDriver` and clean up after themselves.
- **E2E tests** (`e2e/`): the committed Playwright suite is the acceptance gate for all UI behavior.
- Ensure tests are isolated and don't depend on external state; test both happy path and edge cases.

### UI Verification
- **All UI changes MUST be verified using the Playwright MCP** - After making any change that affects the rendered UI (templates, forms, styling, layout, flash messages, etc.), use the Playwright MCP to navigate the running test application and confirm the change renders and behaves as expected
- Run the test application (see [Test Application](#test-application)) and use the Playwright MCP `browser_navigate`, `browser_snapshot`, and `browser_take_screenshot` tools to inspect the affected pages
- Verify both the visual result and the interactive behavior (e.g. submitting forms, triggering flash messages)
- Verify both the visual result and the interactive behavior (e.g. submitting forms, triggering flash messages), then lock the behavior in as a committed spec in `e2e/`.

## Development Commands

Based on the `composer.json` scripts section:

### Code Quality & Testing
- `composer analyse` - Run PHPStan static analysis (level 8)
- `composer check-style` - Check code style with ECS (Easy Coding Standard)
- `composer fix-style` - Fix code style issues automatically with ECS
- `composer phpunit` - Run PHPUnit tests

### Static Analysis

#### PHPStan Configuration
PHPStan is configured in `phpstan.neon` with:
- **Analysis Level**: max (strictest)
- **Extensions**: Auto-loaded via `phpstan/extension-installer`
- `phpstan/phpstan-symfony` - Symfony framework integration
- `phpstan/phpstan-doctrine` - Doctrine ORM integration
- `phpstan/phpstan-phpunit` - PHPUnit test integration
- `jangregor/phpstan-prophecy` - Prophecy mocking integration
- **Symfony Integration**: Uses console application loader (`tests/console_application.php`)
- **Doctrine Integration**: Uses object manager loader (`tests/object_manager.php`)
- **Exclusions**: Test application directory and Configuration.php
- **Baseline**: Generate with `composer analyse -- --generate-baseline` to track improvements
- `composer analyse` - Run PHPStan static analysis (level max)
- `composer check-style` / `composer fix-style` - Check/fix code style with ECS
- `vendor/bin/phpunit --testsuite unit` - Run unit tests (no database needed)
- `vendor/bin/phpunit --testsuite functional` - Run functional tests (needs the test app database, `APP_ENV=test`)
- `vendor/bin/rector --dry-run` - Rector check (CI runs this)
- `vendor/bin/infection` - Mutation tests (scoped to the unit suite; MSI thresholds are unset until the plugin is feature-complete)
- `(cd e2e && npx playwright test)` - Run the e2e suite (needs the test app served with seeded fixtures; the config boots `php -S` itself)

### PHPStan Configuration
PHPStan runs at **level max** over `src` and `tests` with the Symfony/Doctrine/PHPUnit/Prophecy extensions (auto-loaded via `phpstan/extension-installer`). Loaders live under `tests/PHPStan/` (`console_application.php`, `object_manager.php`); the test application directory is excluded.

### Test Application
The plugin includes a test Symfony application in `tests/Application/` for development and testing:
- Navigate to `tests/Application/` directory
- Run `yarn install && yarn build` to build assets
- Use standard Symfony commands for the test app
The plugin includes a Sylius test application in `tests/Application/`:
- Use **Node 20** (`.nvmrc`; the Sylius UI toolchain is incompatible with Node 22): `nvm use && yarn install && yarn build`
- Database (MySQL/MariaDB on 127.0.0.1, see `.env`): `APP_ENV=test bin/console doctrine:database:create && APP_ENV=test bin/console doctrine:schema:create && APP_ENV=test bin/console sylius:fixtures:load default -n`
- Serve with `APP_ENV=test php -S 127.0.0.1:8080 -t public` (or `symfony serve`)
- **Sylius Backend Credentials**: Username: `sylius`, Password: `sylius`

### Console Commands (shipped by the plugin)
- `setono:sylius-loyalty:trigger-birthdays` - fires the customer_birthday earning trigger for customers whose birthday is today (cron, daily; source identifier `birthday:<year>` makes re-runs no-ops)
- `setono:sylius-loyalty:prune-dry-run-results [--days=30]` - prunes dry-run audit rows (cron, daily)
- `setono:sylius-loyalty:expire-points [--batch-size=100]` - writes expiration entries for lots past expiry, incl. zero-point closers (cron, daily)
- `setono:sylius-loyalty:verify-ledger [--batch-size=100]` - checks the ledger invariants; non-zero exit on violations, never fixes (cron-able)
- `setono:sylius-loyalty:recalculate-balances [--batch-size=100] [--force]` - reports balance drift vs the ledger sum; `--force` corrects it
- `setono:sylius-loyalty:inspect-account <email> <channel> [--format=txt|json]` - ledger + replay-derived lot states + invariant check for one account
- `setono:sylius-loyalty:export-customer-data <email>` - GDPR JSON export of a customer's loyalty data
- Also document in host crons: Sylius core's `sylius:cancel-unpaid-orders` (cancellation triggers redemption rollback + clawback)

## Extension Points

Every extension interface is registered for autoconfiguration — implementing the interface is the entire integration surface.

- **Earning condition types**: implement `EarningRule\Checker\ConditionCheckerInterface` (tag `setono_sylius_loyalty.earning_condition`).
- **Amount types**: implement `EarningRule\Amount\AmountCalculatorInterface` (tag `setono_sylius_loyalty.earning_amount`).
- **Expression functions**: implement `Expression\Function\ExpressionFunctionInterface` (tag `setono_sylius_loyalty.expression_function`); metadata feeds autocompletion and the reference panel automatically.
- **Earning triggers**: create an event class extending `Event\Trigger\EarningTriggerEvent`, register it under `setono_sylius_loyalty.triggers`, and fire it with a plain event dispatch. The subclass's public readonly properties become typed expression context. `sourceIdentifier` defaults to the trigger code ("once per account, ever"); repeatable triggers pass their own.
- **Custom transaction types**: subclass `CreditLoyaltyTransaction`/`DebitLoyaltyTransaction`, implement `getDiscriminator()`, and register the class as a Sylius resource — the discriminator map picks it up automatically.
- **Channel resolution for triggers**: re-alias `Resolver\TriggerChannelResolverInterface`.
- **Tier qualification bases**: implement `Tier\QualificationBasis\TierQualificationBasisInterface` (tag `setono_sylius_loyalty.tier_qualification_basis`); the tier form's basis select and unit hint update automatically.
- **Partial-refund clawback**: call `Ledger\LoyaltyLedgerInterface::clawback(OrderInterface $order, int $points)` from any refund mechanism.
- **Referral fraud checks**: implement `Referral\FraudCheck\ReferralFraudCheckInterface` (tag `setono_sylius_loyalty.referral_fraud_check`); any returned flag rejects the referral, admins can override.

## Bash Tools Recommendations

Use the right tool for the right job when executing bash commands:
Expand All @@ -75,22 +111,12 @@ Use the right tool for the right job when executing bash commands:
- **Interacting with JSON?** → Use `jq` (JSON processor)
- **Interacting with YAML or XML?** → Use `yq` (YAML/XML processor)

Examples:
- `fd "*.php" | fzf` - Find PHP files and interactively select one
- `rg "function.*validate" | fzf` - Search for validation functions and select
- `ast-grep --lang php -p 'class $name extends $parent'` - Find class inheritance patterns

## Architecture Overview

### Translations
The plugin provides multilingual support through translation files in `src/Resources/translations/`:

- **Translation Files**: Available in 10 languages (en, da, de, es, fr, it, nl, no, pl, sv)
- **Translation Domains**:
- `messages.*` - General UI translations
- `flashes.*` - Flash message translations (success/error messages)
Customer- and admin-facing strings are translatable via `src/Resources/translations/` (domains `messages`, `flashes`, `validators`). English is authored first and is authoritative; the shipped locale set is en, da, sv, no, fi, de, fr, es, it, nl, pl, pt, cs, hu, ro, uk. Generated locale files carry a header comment marking them machine-translated pending native review. Key namespaces: `setono_sylius_loyalty.ui.*`, `setono_sylius_loyalty.form.*`.

Key translation keys:
- `setono_sylius_loyalty.ui.*` - UI labels
- `setono_sylius_loyalty.form.*` - Form field labels
- `setono_sylius_loyalty.single_message` - A flash message
### Known deviations from the original specification
- The product-page earn hint renders via the `sylius.shop.product.show.add_to_cart_form` template event (just above the add-to-cart button) — Sylius 1.14 has no template event directly below the button.
- The order-payment `pay` transition lives on the `sylius_order_payment` state machine graph (not `sylius_payment`).
- Doctrine mappings live in `src/Resources/config/doctrine/model/` (the `AbstractResourceBundle` default).
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2025 Setono
Copyright (c) 2026 Setono

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
Loading
Loading