Conversation
…nfection MSI thresholds during development
…-bundle isolation The functional suite runs against the Sylius test application in the integration-tests CI job (which has MySQL); the unit suite runs everywhere else, and Infection/coverage are scoped to it.
…lication The suite lives in e2e/ with a webServer booting tests/Application via php -S, runs single-worker against a fixture-seeded database, and gets a dedicated CI job that builds the test app, loads fixtures, and uploads traces on failure. Also replaces the skeleton's leftover acme database name and JWT passphrase in the test app's .env.
… configuration tree The bundle now extends AbstractResourceBundle (Doctrine ORM only, XML mappings under Resources/config/doctrine/model) and the extension extends AbstractResourceExtension, registering resources from the new 'resources' config node. The placeholder 'option' node is replaced by the plugin's actual settings: manual_adjustment_reasons, triggers, transaction_types, and expression_editor.cdn_base_url.
Replaces the skeleton's TODO overview and stale claims (PHPStan loader paths, level 8, phantom translation files) with the plugin's purpose, domain glossary, conventions, testing policy including the new functional and e2e suites, and test app setup instructions.
One account per (customer, channel) with cached balance/lifetimeEarned derived from the ledger, and one program row per channel holding the channel's loyalty parameters. Both are registered as Sylius resources so host projects can override the classes the standard way; mappings are mapped superclasses that resource-bundle flips to entities when the default model is used.
…mpotency constraints Single-table inheritance rooted at LoyaltyTransaction with abstract credit/debit intermediates and eight concrete types (earn_order, earn_action, redeem, redeem_rollback, manual_credit, manual_debit, expire, clawback; earn_referral follows in Phase 3). Idempotency is enforced at the database level: unique (account, type, order), (type, earn), (account, type, source_identifier), and (type, redeem). The clawback's informational order reference gets its own column so the shared order constraint cannot reject the dual clawback of a cancelled referral order. Replay and expiry indexes match the spec, and a loadClassMetadata listener lets host projects register custom transaction types via bundle config.
Rules pair a trigger with conditions (all/any matching, child collection of typed condition rows) and an amount (type + configuration, or expression mode), scoped to order, taxon, or product with priority and stackability. Dry-run rules log would-be awards to the audit table instead of the ledger.
Custom repositories cover the ledger's query needs: replay-ordered ledger reads, earn/redeem lookups by order, rollback existence, expired-open-lot account selection, and the signed points sum. The account and program providers create rows lazily on first access and survive concurrent creation by catching the unique violation, resetting the entity manager, and re-fetching the winning row.
Every plugin exception implements the ExceptionInterface marker. Automated ledger writes get mutable pre-events (AwardingPoints, RedeemingPoints as cancel-only, ExpiringPoints where cancelling defers the lot, ClawingBackPoints) and immutable post-event notifications; redemption rollback and manual adjustments deliberately ship notification-only.
Replays an account's ledger in occurredAt/id order: credits open lots, expiration entries zero exactly their referenced lot (recording the remaining beforehand for invariant checking), and every other debit consumes open, non-expired lots in expiresAt ASC NULLS LAST order. Shortfalls are carried as a deficit and served by the next lots opened. No lot state is ever stored - this is the single source of lot remainders for expiry, rollback attribution, verification, and inspection.
Every write wraps in a transaction, re-finds the account under a pessimistic write lock, dispatches the mutable pre-event, appends the entry, maintains the cached balance and lifetimeEarned, and dispatches post-events after commit. Unique constraint violations are idempotent no-ops (with an entity manager reset, since Doctrine closes it); plugin exceptions also reset the manager before bubbling. Redemption re-checks balance and enabled state inside the lock; clawbacks honor the clamp_to_zero policy at write time; rollbacks restore the redeemed points as a new lot carrying the earliest surviving expiry attributed by replay; expiry writes zero-point closers for fully consumed lots. A NullTierEvaluator fills the Phase 2 tier evaluation seam.
…d-service registries Six shipped condition types (order_total_at_least, cart_contains_taxon, customer_group, nth_order, date_window, day_of_week) and three amount types (fixed - per matching unit under item scopes, per_amount, multiplier) registered via setono/composite-compiler-pass registries. Both extension interfaces are autoconfigured so host apps integrate by implementing the interface alone.
…ultiplier semantics Items are claimed at the most specific matching scope (product > taxon), with unclaimed items forming the remainder of order-scoped rules. Within a competitor set all stackable rules apply and sum; any non-stackable rule sends the single highest-priority one in alone. Multiplier rules evaluate after base rules (cumulative when stackable) and the program's rounding is applied once on the final total. Dry-run rules evaluate alongside live rules for realistic claiming but never affect the live award. Covers the spec's acceptance examples including the 3x40+1x60=180 exclusive-claiming case.
…functions Expressions evaluate against curated view objects (public properties, dot syntax), never entities — the whitelist is physically enforced at evaluation time, not just validated. The ExpressionCatalog is the single typed class-to-members map that drives sandbox validation (AST walk rejecting unknown variables, non-whitelisted members, method calls), the editor's autocompletion payload, and the reference panel. Order/basis variables exist only under the order trigger. Ships the six domain functions (taxon_total, product_total, items_of_taxon, is_first_order, orders_count, day_of_week) plus basic math functions, and the expression condition type and amount type for rules.
A trigger is an event class extending EarningTriggerEvent, registered via one line of bundle config and fired with a plain event dispatch. The compiler pass (running before RegisterListenersPass) validates inheritance and code uniqueness, builds the trigger catalog (labels + typed context properties via reflection), and tags the handler once per concrete event class — Symfony dispatches by concrete class name. The processor resolves the channel (explicit -> shop context -> latest order -> single enabled channel, unresolved = logged no-op), evaluates the trigger's rules, logs dry-runs, and writes the award; only the idempotency violation is silent. The three built-ins (registration, review approval via both state machine engines, birthdays via a daily command) dogfood the mechanism. Also fixes the PHPStan loaders to boot through the test app bootstrap so env vars resolve.
… state machine hooks The EligibleBasisCalculator computes per-item discounted amounts honoring the program's earningBasis and includeTaxes (redemption adjustments reduce the basis by construction); shipping under order_total feeds a non-item extra that only order-scoped rules can claim. The AwardOrderPoints message carries the order id only and its handler is fully idempotent, so the default sync transport and async transports behave identically. The award moment fires from both state machine engines (winzou callbacks prepended on sylius_order_payment/pay and sylius_order/fulfill, symfony/workflow listener tags) guarded by the program's awardOrderPointsAt and the STATE_PAID check for partial payments. Registration triggers the retroactive guest-order claim when the program enables it.
Proves the vertical slice end to end: lazy account creation, the locked earnAction write, the unique-constraint no-op on redelivery (including the entity manager reset under dama's transaction wrapper), manual adjustments, and the balance cache staying in sync with the ledger sum.
…r, validator, debit and rollback Redemption is a distributed discount, not a payment method: the processor (priority 15, between promotions and taxes) derives the applied points from the persisted request — min(requested, balance, cap) clamped down to a clean multiple of the conversion — and writes unit-distributed negative adjustments following Sylius' order-promotion pattern so VAT computes on the reduced base. The request is never overwritten by clamping. A checkout validator re-checks balance and account state at the complete step; the debit itself runs as a winzou before-callback / workflow transition listener so a failure aborts completion, and cancellation rolls the debit back as a new lot. The host order gets LoyaltyOrderTrait + LoyaltyOrderInterface (the test app's Order demonstrates the setup); a functional test proves the whole flow against fixture data.
…ax action Rendered on the cart summary template event for logged-in customers with a sufficient balance (or an active redemption to manage): preset step buttons derived from the conversion so each maps to a clean currency amount — the one place a currency equivalent of points is shown, rendered through Sylius' money macro — plus the visually dominant Use max, which records the customer's entire balance at click time as the request. A persistent notice explains partial application when clamped, and the checkout summary shows the applied redemption with a change-on-cart link. Verified interactively: Use max on a small cart clamps to the 50% cap and grows back to the full request automatically when the cart grows.
The clawback listener debits the original earn on order cancellation and full payment refund via both state machine engines. expire-points writes one expiration entry per expired open lot (candidates collected up front so listener-deferred lots cannot loop), verify-ledger checks the four ledger invariants with a non-zero exit on corruption and never fixes, recalculate-balances reports drift and only corrects with --force, inspect-account dumps the replay-derived lot states through the shared AccountInspector (which the admin inspector will reuse), and export-customer-data supports GDPR access requests. Customer deletion removes accounts and ledgers by default, or de-identifies them behind an opaque token when retain_anonymized_ledger is enabled.
…al adjustments, and rule tester One 'Loyalty' entry under Marketing opens the dashboard (account count, 30-day earned/redeemed ledger aggregates, navigation cards). Accounts get a grid with click-through to the ledger inspector: replay-derived lot states with consumption attribution, the per-account invariant check, enable/disable, and the manual adjustment form (signed points, configured reason codes, mandatory note, audit-logged admin user) — the only human write path, still through the ledger. Earning rules get full CRUD with the trigger/scope/conditions/amount form (per-type configuration subforms and the CodeMirror expression editor follow), programs a per-channel settings page created lazily with defaults, dry-run results a read-only grid, and the rule tester evaluates any order read-only with an optional evaluation date override, showing per-rule detail and per-item claims. Verified interactively: rule creation, an audit-logged manual debit, and a tester run awarding floor(821.56) = 821 points on a fixture order.
…s to a runtime The customer detail page shows each channel's balance with the latest 25 ledger entries and a link into the ledger inspector. Twig functions now live in a lazily-loaded LoyaltyRuntime (twig.runtime) with the extension holding only the definitions.
…endpoint, buildless assets The ExpressionType serializes the sandbox catalog into the data-catalog attribute — the single declaration driving autocompletion, the reference panel, and server-side validation — plus the lint URL and the configurable CDN base. The admin-only lint endpoint runs the exact validation used on save (verified: whitelisted chains pass, off-catalog members are rejected, order variables are refused under action triggers). The editor glue is hand-written buildless ES modules committed to Resources/public: a pragmatic StreamLanguage tokenizer, a type-graph completion source, debounced linting, and the click-to-insert reference panel; CodeMirror 6 itself loads as version-pinned ESM imports from the configurable CDN. Wiring the editor into the rule form's expression mode follows.
…nake_case
Rules and conditions switching to expression mode get the CodeMirror
editor field: it is populated from the stored configuration, validated on
save with the same parse + whitelist as the lint endpoint narrowed to the
rule's trigger (verified: an off-whitelist member 422s the form, a valid
expression persists into the configuration), and written back as
{"expression": ...}. The trigger-kind constraints are form-validated:
item scopes and the per-amount/multiplier types are refused for action
triggers, and multipliers must stay order-scoped. All template files and
folders now use snake_case.
…story The /account/loyalty section renders the balance hero (with an inactive notice for disabled accounts), the replay-derived expiring-soon callout, and the flat reverse-chronological history: per-type descriptions with order numbers, expiry dates on credit rows only, zero-point lot closers excluded, and a bank-statement running balance computed with one aggregate query for the rows newer than the page plus a backwards walk within it. Verified in the browser: hero 1850, adjustments listed with correct running balances.
The example factories (program, earning rules incl. an expression-mode example, accounts with ledger histories) are public API for host projects' test apps. Account histories are written through the real ledger, so balances, lifetime earned, and lots are genuine — the loaded suite passes verify-ledger. The test app ships a purge-free 'loyalty' suite loaded on top of the default catalog: a base per-amount rule, a dry-run weekend multiplier, a registration bonus, an expression example, and two shop users (2150 and 100 points) seeding functional and e2e tests.
…urrent redemption The order pipeline awards exactly once even when the pay transition fires twice (verified with an isolated channel: 100.00 at 1pt/1.00 credits exactly 100 points, with basis and expiry recorded) and awards nothing for unpaid orders. The concurrency test opts out of dama's transaction wrapping, seeds a committed 500-point account, and races two real PHP processes redeeming 400 points each: exactly one succeeds and the balance lands at 100 — parallel requests can never overspend. CI now loads the default and loyalty fixture suites before the functional and e2e jobs.
… public Specs cover the shop dashboard (exact fixture history with FIFO-consumed expiring lot), cart redemption (apply, price-independent max/clamp, remove, hidden below the minimum), and the admin: dashboard stats, accounts grid to inspector with healthy invariants, a netted-out manual adjustment, the rules grid, the rule tester on a fixture order, and the expression editor (mount, catalog completion, server lint marker, reference-panel insert) with retries absorbing CDN flakiness. Bug found by the suite: winzou state-machine callbacks resolve listeners from the container at runtime, so every callback-referenced listener must be a public service — private ones broke review acceptance and fixture loading. The e2e server now runs on port 8082 with variables_order=EGPCS so a locally running dev server can't be hit by mistake.
…loyalty plugin The awarding message round-trips a real doctrine transport (serialized with just the order id) and a redelivered duplicate still awards exactly once. The README now documents installation (bundle, routes incl. the no-locale variant, the order trait plus doctrine:migrations:diff, no shipped migrations), the full configuration surface, the cron table with the core unpaid-order canceller, consumer-law and base-currency notes, CSP/CDN guidance for the expression editor, and the extension-point cookbook index.
…cookbook Every customer- and admin-facing string ships in da, sv, no, fi, de, fr, es, it, nl, pl, pt, cs, hu, ro, and uk, each file headed with a machine-translated-pending-native-review marker; English remains authoritative. The cookbook covers the eight Phase 1 extension recipes: condition types, amount types, triggers, expression functions, transaction types, pre-event interception, rendering the balance anywhere, and partial-refund clawback.
…tor, nightly command Tiers are a full per-channel resource (code, position, basis, threshold, earning multiplier, badge color, translatable benefits). Qualification bases are an extension point (tag setono_sylius_loyalty.tier_qualification_basis, autoconfigured, composed into a registry) shipping points_earned (qualifying credits, the lifetimeEarned definition), amount_spent, and orders_count, each windowed by the program's calendar-year / rolling-12- months / lifetime setting. The evaluator upgrades immediately inside the same ledger transaction after qualifying earns (TierChanging is cancellable, TierChanged is the comms hook) and never downgrades inline; the nightly evaluate-tiers command reconciles enabled accounts, applying downgrades immediately or after the program's grace period tracked via tierBelowThresholdSince. The tier's multiplier applies in the earning pipeline after all rules, immediately before rounding. Eight unit tests cover upgrade, highest-qualifying selection, inline-downgrade refusal, cancellation, and the grace lifecycle.
Merchants can gate stock promotions by tier ('Gold gets free shipping'):
the checker compares the customer's tier position on the order's channel
against a configured minimum tier, refusing guests, disabled accounts,
and tierless customers. The configuration form offers the existing tiers
grouped by channel.
…board widgets Tiers get a grid and CRUD under the loyalty dashboard (verified: a Silver tier created through the form persists with its translated benefits, and reconciliation then assigns it to the two accounts whose windowed points_earned passes the threshold while the 100-point account stays untiered). The program form gains the evaluation window, downgrade grace days, and the two earn-hint toggles; the accounts grid and ledger inspector show the tier (color badge). The dashboard adds the Phase 2 widgets: outstanding liability from the scheduled per-channel snapshot written by the new calculate-liability command (sum of replay-derived open-lot remainders — verified at exactly the sum of seeded balances), the 90-day redemption rate, and 90-day active accounts.
The loyalty dashboard hero shows the tier badge with translated
benefits, a progress bar toward the next tier fed by that tier's own
basis and window ('X / Y to Gold'), and a celebratory top-tier state —
never maintenance-threat messaging. Earn hints render via the rule
engine's read-only path with a never-overstate filter: condition
checkers now declare whether they need a customer or the real cart, and
rules are excluded when the hint context lacks either (expression rules
are excluded for guests). The product-page hint (above the add-to-cart
button — the documented deviation) evaluates a synthetic one-item cart
per enabled variant and swaps the number on variant change through a
server-rendered map without XHR; the cart hint evaluates the real cart.
Both are program-gated and hidden for disabled accounts. Verified in the
browser: 83 points on an 83.00 product for an anonymous visitor, the
Silver badge with top-tier celebration, and a cart hint including the
tier's 1.25 multiplier.
…ession test Two bugs found by a functional regression test for spec §7.3 (an earn crossing a threshold upgrades within the same request): the ledger evaluated tiers before flushing the new credit, so the database-derived qualification metric missed the very earn that crossed the threshold — credits now flush (inside the ledger transaction) before evaluation; and the evaluation window's end bound was exclusive, dropping rows written in the same second as the evaluation — now inclusive in all three shipped bases. The loyalty fixture suite gains Silver (1000, ×1.25) and Gold (5000, ×1.5) tiers through a new public tier example factory, and seeded accounts now come out of a fresh load already tiered. New Playwright specs cover the shop badge/progress/celebration, both earn hints, and the tier admin.
…rds, clawback
Accounts get lazy Crockford-base32 referral codes with a /r/{code}
landing URL (30-day attribution cookie, redirect home) and a query-
parameter listener recognizing ?ref=CODE on any shop URL — last click
wins, no registration form changes. A valid cookie at registration
creates a pending referral (once per referee per channel, DB-enforced).
Qualification shares the award moment: the referee's first post-
attribution order decides once — below the minimum it stays pending
until expiry; any fraud flag rejects it (admins can override, which
requalifies and rewards); clean qualification credits both parties
through the new earn_referral ledger type (idempotent per account and
referral, disabled accounts skipped not deferred) and marks it rewarded.
Shipped fraud checks (an autoconfigured extension point): self-referral
by customer, normalized email (+alias and dot stripping), and fuzzy
address; opt-in registration-IP hash reuse; referee account age; and a
30-day reward cap. Cancelling or refunding the qualifying order claws
back both rewards via the new generic clawbackCredit — and both clawback
paths now guard with a lookup before writing, because an idempotent
replay that trips the unique constraint would reset the entity manager
mid-request (found by the lifecycle test). The daily expire-referrals
command expires stale pending referrals per the program window and
purges IP hashes after 90 days. Admin gets a filterable referrals grid
with inline fraud-flag display and the approve-anyway override; the shop
dashboard gains the share block (copy / Web Share API) with aggregate
stats only. Three functional tests pin the acceptance criteria: dual
idempotent rewarding, self-referral rejection, first-order-decides, and
dual idempotent clawback.
…eck recipe Measured at feature-complete: 22% MSI (UI actions, commands, and listeners are covered functionally and end-to-end rather than by unit tests) with 100% covered-code MSI — every mutant in unit-covered code is killed. Pinned at 20/95 to catch regressions without blocking unrelated work.
…ings 51 messages keys (tier settings and form, promotion rule, qualification bases, earn hints, referrals, the new dashboard stats) and 3 flashes keys per locale — 810 machine-translated strings pending native review, verified for key parity and placeholder integrity against English.
- Custom transaction types no longer need plugin config: the discriminator map is fed from %sylius.resources% — any registered resource model extending the transaction root is added under its own static getDiscriminator() value; the transaction_types node is gone. - All repositories moved from src/Doctrine/ORM to src/Repository, sharing the namespace with their interfaces. - The expression view layer is gone: expressions evaluate against entities directly through a single generic getter bridge (EL's dot syntax only reads public properties), with the catalog whitelist enforced at save and lint time as before. - LoyaltyOrderInterface now extends the core OrderInterface; EarningTriggerEvent::getTriggerCode() is renamed to getCode(). - The menu listeners are event subscribers in src/EventSubscriber; ToggleAccountAction gets the repository instead of the entity manager; DryRunLogger uses setono/doctrine-orm-trait. - The trigger channel resolver's misleading comment is fixed. Pushed back on converting ProgramIndexAction to a resource index: a grid can only list existing rows, while the page must surface channels whose program has not been lazily created yet — rationale now in the class docblock. All finished TODOs removed.
…r bundle, lowest-deps
Four distinct root causes from the first CI run on master:
- The accounts grid 500ed for accounts without a tier ('tier.name' on a
null relation) — the tier column is now a null-safe badge template.
The referrals grid 500ed because the override column read a
nonexistent property — it now gets the row itself (path '.').
- The expression editor was flaky everywhere for a reason that had
nothing to do with the CDN: CodeMirror recomputes view.dom's className
on every update, silently wiping the externally added marker class the
e2e spec (and any consumer) selects by. The class now comes from an
editorAttributes extension, the supported persistent mechanism.
- CI still shouldn't depend on CDN reachability (plan risk R11): the
test app ships a vendored single-instance CodeMirror bundle with plain
.js shims, the loader treats a relative cdn_base_url as self-hosted
files, and the test app points at the snapshot. The full e2e suite
dropped from ~5 minutes to ~20 seconds. Production keeps the esm.sh
default; the README documents the self-hosting recipe.
- Lowest-deps: PHPStan crashed on a php-parser v4/v5 API mix (dev floor
now ^5.0); the redemption functional test's expectation now derives
from a request-less processing run so it holds across Sylius patch
versions and fixture pricing; the dependency analyser no longer fails
on ignores that CI's split-package resolution leaves unmatched. The
ledger inspector spec reads the suite's read-only account so local
re-runs stay deterministic.
Verified: 17/17 Playwright specs green in 20s on a fresh database, all
PHP gates green.
…or, split-package requires - The e2e job never ran assets:install, so the editor's module script 404ed and the editor could not mount in CI at all — the CDN was never the (whole) story. The job now installs bundle assets after building. - The lowest-deps PHPStan crash was symfony/translation 6.4.0 calling the php-parser v4 API inside PHPStan's process (which bundles v5) when the console loader boots the app; the dev floor is now ^6.4.7 where the extractor uses the version-aware factory. The php-parser workaround from the previous attempt is dropped. - composer.json now declares every directly used split package (sylius components and bundles, symfony/form, http-foundation, routing, validator, options-resolver, security-csrf and -bundle, translation-contracts, twig, ext-mbstring) — under the monorepo dev install these resolve to sylius/sylius and look unused, so the analyser ignores that error type for them symmetrically with the existing monorepo ignores. - The clamped-redemption functional test is now fully deterministic: an isolated channel with an explicitly priced variant (2 x 9.50), so no fixture promotions, taxes, or random pricing can shift the cap base on any Sylius patch version.
…loors, nullsafe inference composer.json is composer-normalize-ordered again (the split-package additions had broken it). Two more lowest-deps floors: twig ^3.10, whose TwigFunction signature accepts [class, method] runtime callables that twig 3.0 rejected under static analysis, and doctrine/data-fixtures ^1.7, whose ORMPurger no longer calls the DBAL 2 schema-filter API that made fixture loading crash. The tier promotion rule checker's nullsafe chain is restructured into explicit null checks, which reads better and analyses identically across dependency sets.
Fixture prices are random per load; a small enough cart rendered no presets in CI. Twenty-five units keep the items total comfortably above the redemption minimum on any generated price.
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. Thanks for integrating Codecov - We've got you covered ☂️ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the full
.notes/sylius-loyalty-plugin.mdspecification (v0.39) in 51 commits.Phase 1 — Points
verify-ledger. Derived FIFO lot accounting via deterministic replay (no stored remainders)./account/loyaltywith bank-statement running balances).Phase 2 — Tiers
Translatable tier resource; extensible qualification bases (points earned / amount spent / orders count, windowed); immediate in-transaction upgrades, nightly reconciliation with grace-period downgrades; earning multiplier;
customer_loyalty_tierpromotion rule checker; shop badge/progress/celebration; variant-aware, never-overstating earn hints; liability snapshot + redemption-rate dashboard widgets.Phase 3 — Referrals
Lazy Crockford codes,
/r/{code}+?ref=attribution (30-day cookie, last click wins), registration-time pending referrals, first-post-attribution-order qualification, extensible fraud checks (self-referral, opt-in IP hash, account age, reward cap), dual idempotent rewards and clawback, expiry command, admin grid with override, shop share block.Verification