Skip to content

Loyalty plugin: points, tiers, and referrals#1

Open
loevgaard wants to merge 51 commits into
masterfrom
fable
Open

Loyalty plugin: points, tiers, and referrals#1
loevgaard wants to merge 51 commits into
masterfrom
fable

Conversation

@loevgaard

Copy link
Copy Markdown
Member

Implements the full .notes/sylius-loyalty-plugin.md specification (v0.39) in 51 commits.

Phase 1 — Points

  • Append-only ledger (Doctrine STI, 9 transaction types) with DB-level idempotency constraints and pessimistically locked writes; balances are derived caches, verifiable with verify-ledger. Derived FIFO lot accounting via deterministic replay (no stored remainders).
  • Rule engine: triggers (order, registration, review, birthday, custom event classes), 7 condition types, 4 amount types, exclusive item claiming (product > taxon > order), stacking/priorities, dry-run mode with an audit list.
  • Expression mode: sandboxed ExpressionLanguage with a catalog-driven CodeMirror editor (autocompletion, server-side inline linting, click-to-insert reference panel). One typed catalog drives completion, linting, and save validation.
  • Checkout redemption: persisted request, applied amount re-clamped on every recalculation (grows back automatically), unit-distributed adjustments so taxes compute on the reduced base; synchronous debit at completion, rollback + clawback on cancellation/refund.
  • Expiry/clawback/GDPR commands, admin (dashboard, grids, ledger inspector with invariant checks, manual adjustments, rule tester) and shop UI (cart widget, /account/loyalty with 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_tier promotion 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

  • 67 unit + 12 functional tests (double dispatch, cross-process concurrency, async-transport redelivery, referral lifecycle, inline tier upgrade)
  • 17 Playwright specs (~20s, hermetic via a vendored CodeMirror bundle)
  • PHPStan level max, ECS, Rector, composer-normalize, dependency analysis, mutation thresholds pinned (22% MSI / 100% covered-code MSI)
  • 16 locales, README + 10 cookbook recipes
  • CI green across the full matrix (PHP 8.1–8.3 × lowest/highest deps)

loevgaard added 30 commits July 6, 2026 17:44
…-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.
loevgaard added 21 commits July 6, 2026 20:48
…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.
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

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 ☂️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant