Architecture
{/* AUTO-GENERATED from ../docs/architecture.md by scripts/sync-dev-docs.mjs — do not edit by hand. */}
Start here: if this is your first pass at the codebase, read
agency-dev-overview.mdfirst — it’s a 30-minute orientation layered on top of this deep-dive, with the pitfalls and extension-point pointers agencies need.
Last verified against source: 2026-10-15 (commit 951e75a67, plugin 4.60.2).
Rule model. The §3 engine internals below describe the section-evaluator model that ships today. A rule is seven section buckets (
who,when,where,trigger,rewards,messaging,metadata) plus a few top-level fields (RuleFields::allowed_input_fields()). The live JSON Schema is served at/wp-json/dino-discounts/v1/schema, the per-section sanitiser isRuleSanitizer, and the engine consumers are the section evaluators inincludes/Engine/Evaluators/(§3.2.1). The old tree-shaped surface —targeting_treeand theTargetingEvaluatorswitch — no longer exists. The design that drove the rebuild is archived in.agents/exploration/rule-shape-rebuild-2026-05-16/DESIGN.md; the rollback playbook lives atdocs/process/data-model-rebuild-rollback-runbook.md.
Audience: intermediate-to-senior PHP/JS developers who haven’t read this plugin
yet — agency partners extending it, and integrators making non-trivial changes.
If you only need to list hooks, see hooks.md. For merchant-facing
guidance, see merchant-guide.md. For the day-one agency
orientation (quick-start, extension patterns, pitfalls), see
agency-dev-overview.md.
1. High-level component map
Section titled “1. High-level component map”graph TB subgraph Browser["Browser — wp-admin"] React["Admin React SPA<br/>src/ build to compiled/index.js"] end
subgraph WP["WordPress runtime"] Bootstrap["dino-discounts.php<br/>bootstrap"] Plugin["Plugin<br/>DI container and wiring"] Migrations["Migrations<br/>version-gated upgrades"] Seeder["StarterData<br/>ResetManager + StarterRuleSeeder"] Store["RuleStore<br/>sanitise-then-write"]
subgraph API["REST API — namespace dino-discounts/v1"] RestAPI["RestAPI"] Rules["RulesController"] Settings["SettingsController"] Snapshots["SnapshotsController"] Preview["PreviewController"] Analytics["AnalyticsController"] CouponCodes["CouponCodesController"] CartScen["CartScenariosController"] Schema["RuleSchemaController"] More["…and 13 more<br/>(see RestAPI.php)"] end
subgraph Engine["Rule engine — stateless per request"] RE["RulesEngine"] RM["RuleMatcher"] SE["Section evaluators<br/>Who / Where / When / Trigger"] EI["Per-item pool match<br/>trigger.pools, rewards.reward_target"] UT["UsageTracker + PerCustomerUsageTracker<br/>DB-backed"] RS["RuleSanitizer + RuleValidator"] Strat["UnifiedStrategy<br/>dispatched on rewards.kind"] AT["AnalyticsTracker<br/>background-writes orders"] end
subgraph Cart["Storefront integration"] CC["CartCoupons<br/>virtual coupons + AppliesDiscount trait"] CCI["CouponCodeInterceptor<br/>priority 9 by default"] CCM["CouponRepository + CouponManager<br/>coupons and bulk code pool"] SAE["StoreApiExtension<br/>WC Blocks, read-only surface"] SF["Storefront/*<br/>display, spend-more nudge, filter"] end
subgraph Diag["Diagnostics and ops"] PM["PerformanceMonitor"] CD["ConflictDetector"] Log["Observability Logger"] Snap["History RuleSnapshots"] end end
subgraph OptDB["wp_options (shared table)"] OptRules[("dino_discounts_active_rules")] OptSettings[("dino_discounts_global_settings")] OptMsg[("dino_discounts_message_settings")] OptVersion[("dino_discounts_version")] OptScenarios[("dino_discounts_cart_scenarios")] OptFirstPub[("dino_discounts_first_publish_ts")] end
subgraph CustomDB["Custom tables (prefix wp_dino_)"] TblUsageCounts[("wp_dino_usage_counts")] TblUsageStats[("wp_dino_usage_stats")] TblCustUses[("wp_dino_rule_customer_uses")] TblSnapshots[("wp_dino_rule_snapshots")] TblAnalytics[("wp_dino_analytics_events")] TblCoupons[("wp_dino_coupons")] TblBulk[("wp_dino_bulk_codes")] TblGuest[("wp_dino_codes_guest_uses")] TblGenLog[("wp_dino_coupon_generation_log")] end
React -- "fetch /wp-json/dino-discounts/v1/*" --> RestAPI Bootstrap --> Plugin Bootstrap --> Migrations Plugin --> API Plugin --> Engine Plugin --> Cart Plugin --> Diag
Rules --> RS Rules --> Store Rules --> Snap Rules -- "reset-to-seeded" --> Seeder Seeder --> Store Snap -- "restore" --> Store Store -- "update_option" --> OptRules Settings -- "update_option" --> OptSettings
RE -- "object cache, then get_option" --> OptRules RE --> RM RM --> SE RM --> EI RE --> UT RE --> Strat
UT -- "SELECT/UPDATE" --> TblUsageCounts UT -- "INSERT/UPDATE" --> TblUsageStats UT -- "INSERT/UPDATE" --> TblCustUses
Snap -- "INSERT/UPDATE" --> TblSnapshots
AT -- "background INSERT" --> TblAnalytics
CCI --> CCM CCM -- "SELECT/UPDATE" --> TblCoupons CCM -- "SELECT/UPDATE" --> TblBulk CCM -- "INSERT" --> TblGenLog CouponCodes --> CCM CCI --> CC CC --> RE SAE --> CC SF --> RE
Migrations -- "get/update_option" --> OptVersion Migrations -- "CREATE TABLE IF NOT EXISTS" --> CustomDB
RestAPI.phpis the registrar: it registers 21 controllers onrest_api_init(includes/API/RestAPI.php:77–106), plusTelemetryErrorControllerwhen a telemetry endpoint is configured. Only the ones that matter for this page are drawn. Every controller extendsAbstractController(includes/API/AbstractController.php) for its sharedcheck_permission()callback.VirtualCart.php,VirtualProduct.php, andCartScenarioDefaults.phpare data-shaping helpers rather than route owners.
| Layer | Purpose | Entry points |
|---|---|---|
| Bootstrap | Plugin loader, constants, autoload | dino-discounts.php, includes/Plugin.php |
| Admin React SPA | All rule authoring UI, settings, analytics, history | src/index.js, src/App.js, built to compiled/index.js and mounted on #dino-discounts-admin-app (rendered by includes/Admin.php) |
| REST API | Single source of truth for admin writes and previews | includes/API/RestAPI.php registers routes on rest_api_init under dino-discounts/v1 |
| Rule engine | Per-request evaluation of rules → discount results | includes/Engine/RulesEngine.php |
| Storefront | Cart-level application of discounts; Blocks + classic | includes/Cart/CartCoupons.php is the engine entry point. includes/Cart/StoreApiExtension.php is read-only — it surfaces CartCoupons::get_pending_discounts() into the WC Store API response for Blocks checkout, but does NOT invoke RulesEngine itself (includes/Cart/StoreApiExtension.php:880). includes/Storefront/* covers display, the storefront filter and the spend-more nudge. |
| Persistent coupon codes | Real (non-virtual) codes a shopper can type at checkout | includes/Cart/CouponCodeInterceptor.php (hooks woocommerce_get_shop_coupon_data, priority 9 by default) resolves typed codes through CouponResolver + CouponRepository. includes/Cart/CouponManager.php manages the bulk code pool. Backed by wp_dino_coupons, wp_dino_bulk_codes and wp_dino_coupon_generation_log. See §3.5. |
| Analytics (write path) | Order-level discount events written in the background | includes/Engine/AnalyticsTracker.php — hooks woocommerce_checkout_order_processed and the order-status transitions, enqueues a background dino_discounts_track_analytics action, writes to wp_dino_analytics_events. |
| Analytics (read path) | Exposure to admin via REST | includes/API/AnalyticsController.php — aggregates from wp_dino_analytics_events for the admin dashboard. |
| Diagnostics | Performance monitor, conflict/caching warnings, structured logs | includes/Diagnostics/PerformanceMonitor.php, includes/ConflictDetector.php, includes/Observability/Logger.php |
| History | Rule snapshots (undo / audit) | includes/History/RuleSnapshots.php — persists to wp_dino_rule_snapshots (NOT to wp_options). Snapshot restore writes the chosen snapshot back into dino_discounts_active_rules through RuleStore. |
| Starter data | Sample rules for onboarding and resets | includes/StarterData/StarterRuleSeeder.php builds the sample rules; includes/StarterData/ResetManager.php persists them when the admin calls POST /rules/reset-to-seeded. |
| Migrations | Idempotent, version-gated data upgrades + custom-table creation | includes/Migrations.php — maybe_upgrade() runs on admin_init (dino-discounts.php:620). Also owns create_custom_tables(), which issues CREATE TABLE IF NOT EXISTS for all nine wp_dino_* tables. |
2. Data flow: admin save → storefront display
Section titled “2. Data flow: admin save → storefront display”sequenceDiagram autonumber participant U as Merchant participant R as React SPA participant API as RulesController participant Options as wp_options participant Shopper participant WC as WC_Cart participant CC as CartCoupons participant RE as RulesEngine
U->>R: Edit rule, click Publish R->>API: POST /dino-discounts/v1/rules API->>API: RuleSanitizer sanitize_rule (each rule) API->>Options: RuleStore persist_active → update_option dino_discounts_active_rules API->>API: RuleSnapshots save_snapshot API->>API: do_action dino_discounts_rules_saved API-->>R: 200 and sanitized payload
Shopper->>WC: Add or change cart item WC-->>CC: woocommerce_before_calculate_totals (priority 20) CC->>CC: fingerprint cart, check memo alt cache hit CC->>WC: reuse prior discount results else cache miss CC->>RE: evaluate_cart with cart, total, currency, country RE->>Options: get_option dino_discounts_active_rules (object-cached) RE->>RE: global gates (kill-switch, coexistence yield, disable_all) loop each active rule RE->>RE: RuleMatcher::evaluate_admission (who → where → when → trigger) alt admission passes RE->>RE: UnifiedStrategy on rewards.kind (trigger pool-stack gate, then reward pool), produces DiscountResult end end RE-->>CC: array of DiscountResult end CC->>CC: mint virtual coupon code dino_dd_RULEID CC->>WC: apply_coupon dino_dd_RULEID WC-->>Shopper: Render totals with discount lineKey properties:
- Write paths to
dino_discounts_active_rules. Every runtime writer goes throughRuleStore::persist_active()(includes/Settings/RuleStore.php:85), which re-sanitises every rule and then writes the stored node tree withupdate_option(…, false).- Admin UI —
RulesController::save_rules()(includes/API/RulesController.php:656) sanitises, then callsRuleStore::persist_active()at line 813 and saves a snapshot; if the snapshot fails, the active rules are rolled back. The draft autosave (PUT /rules/draft) writes the separatedino_discounts_draftoption throughRuleStore::persist_draft(). - Snapshot restore —
RuleSnapshots::restore_snapshot()atincludes/History/RuleSnapshots.php:245writes the chosen snapshot back throughRuleStore::persist_active()(line 344). The snapshots themselves live inwp_dino_rule_snapshots, notwp_options. - Reset to sample rules —
POST /rules/reset-to-seededrunsResetManager::execute(), which builds rules withStarterRuleSeeder::build_starter_rules()(includes/StarterData/ResetManager.php:86) and persists them at line 289. The demo-store seeder (DemoStoreSeeder::seed_all(),POST /demo-store/seed) also writes throughRuleStore. - Migrations — a few steps in
includes/Migrations.php(e.g.currency-scope-backfill,messaging-per-rule-key-retirement) rewrite stored rules in place viaupdate_active_rules_or_throw()(line 930). This is the one path that does not go throughRuleStore. Steps run once per version step underadmin_init. Apre_update_optionguard (RuleWriteGuard,includes/Engine/Schema/RuleWriteGuard.php:99) also asserts the shape of any write to the option.
- Admin UI —
- Rules are stored as a node tree. The option holds promotion nodes (a named group with a status) containing rule nodes, plus top-level rule nodes for rules outside a promotion (
includes/Settings/NodeTreeStorage.php). The engine walks the tree depth-first on demand; the walk order is the rule order. - Read-path for rules is
get_optiononly.RuleSnapshot::load()(includes/Engine/RuleSnapshot.php:223) readswp_cache_get('rules', 'dino_discounts')first and falls back toNodeTreeStorage::read_raw_active_rules()(aget_optionread) on a miss, then caches into the object cache. ⚠️ Caveat: the engine does query custom tables during evaluation —UsageTrackerreadswp_dino_usage_countsandPerCustomerUsageTrackerreadswp_dino_rule_customer_usesto enforce usage caps (see §3.2.1), andAnalyticsTrackerwriteswp_dino_analytics_eventsafter checkout. The “no custom tables” rule applies only to the rule blob itself. - Two-layer discount memoisation in
AppliesDiscount::resolve_eval_context_and_cache()(includes/Cart/Traits/AppliesDiscount.php:840–875). Both layers key on the same cart fingerprint frombuild_cart_fingerprint()(includes/Cart/Traits/ManagesCartState.php:292). The fingerprint is an MD5 over the cart hash, a digest of the line items, subtotal (ex and inc tax), currency, country, applied real coupons, user ID, hashed billing email, chosen payment and shipping methods, a minute time bucket, anddino_discounts_rules_version. The applied-real-coupons list starts from thewcbucket ofCartCoupons::partition_applied_coupons()and adds back any Dino code whoseactivation_typeis NOT'virtual'(persistent codes DO contribute; onlydino_dd_*virtual codes are excluded, because they’re re-derived each pass and would cause self-invalidation). User ID0for guests.- Request-scoped:
$last_fingerprint/$last_discountsskip the engine on repeatedwoocommerce_before_calculate_totalspasses within the same PHP request. - Cross-request, 60-second transient:
dino_eval_<fingerprint>survives to subsequent requests, so a shopper refreshing the cart page doesn’t re-run the engine.
- Request-scoped:
- Virtual coupons are the application mechanism. No row ever appears in
wp_posts(shop_coupon) for a Dino discount.
3. Rule engine internals
Section titled “3. Rule engine internals”3.1 Request-scoped object graph
Section titled “3.1 Request-scoped object graph”graph LR CC["CartCoupons<br/>uses AppliesDiscount trait"] --> RE[RulesEngine] RE --> RM["RuleMatcher<br/>(admission + per-item)"] RE --> UT[UsageTracker] RE --> Strategies RM --> SectionEvals["Evaluators/*<br/>WhoEvaluator · WhereEvaluator<br/>WhenEvaluator · TriggerEvaluator"] RM --> PI["Per-item pool match<br/>is_items_section_match"] RM --> UT Strategies --> US["UnifiedStrategy<br/>(extends AbstractStrategy)"] US --> CartLevel["cart_level allocator<br/>rewards.kind: tiered"] US --> GroupProrated["group_prorated allocator<br/>rewards.kind: bulk"] US --> Pooled["pooled_match allocator<br/>rewards.kind: pooled_match"]Locations:
includes/Engine/RulesEngine.php— orchestrator. Loads the rule tree, runs the global gates, dispatches each rule toRuleMatcher→Strategy.includes/Engine/RuleMatcher.php— owns the engine’s matching entry points:evaluate_admission(rule, cart_context)(line 946) ANDs the four cart-level section evaluators (who→where→when→trigger) and short-circuits on first failure. It returns{ pass, trace }, one trace row per section.is_items_section_match(item, rule)(line 280) tests one cart line against the rule’s reward pool (rewards.reward_target). Each section evaluator reads only its own bucket; no cross-section reads inside an evaluator.
includes/Engine/Evaluators/{WhoEvaluator,WhereEvaluator,WhenEvaluator,TriggerEvaluator,PoolSelectorEvaluator}.php— the section evaluators.includes/Engine/Schema/{WhoSchema,WhenSchema,WhereSchema,TriggerSchema,RewardsSchema,RewardTargetSchema,MatchingPoolSelectorSchema,MessagingSchema,MetadataSchema}.php— per-section JSON-Schema fragments. Composed byRuleSchemaController(includes/API/Schema/RuleSchemaController.php) into the wire contract served at/wp-json/dino-discounts/v1/schema. Schema fragments only — the evaluators that consume these buckets live inEngine/Evaluators/.includes/Engine/UsageTracker.php— store-wide total usage counters (consumed by the trigger evaluator viatrigger.usage_limits.total). Custom-table-backed: readswp_dino_usage_counts(O(1) per rule) and writes dimensional aggregates towp_dino_usage_stats.warm_counts()fills a per-request cache ($usage_counts_cache) so each rule is fetched at most once perevaluate_cart()pass.includes/Engine/PerCustomerUsageTracker.php— per-customer counters fortrigger.usage_limits.per_customer, inwp_dino_rule_customer_uses(logged-in customers keyed by user ID, guests by a salted hash of their billing email).includes/Engine/RuleSanitizer.php— canonicalises one section at a time, then callsRuleValidator::assert_cross_section_invariants()(includes/Engine/RuleValidator.php:75): tiers strictly ascending (tiered and bulk; relaxed for draft autosave), bulk tiers sharing one reward kind, pooled-match pool integrity, andwhen.schedule.start ≤ when.schedule.end. JSON Schema is the structural contract;RuleStoreandRuleWriteGuardmake sure nothing reaches storage without passing it.includes/Engine/Strategy/*— a single concreteUnifiedStrategy(extendingAbstractStrategy, implementingDiscountStrategyInterface) handles every built-inrewards.kind.RulesEngine::get_strategy()mapstiered,bulk, andpooled_matchall to the same class;UnifiedStrategy::evaluate()then dispatches internally onrule.rewards.kindinto one of three allocator modes (cart_level/group_prorated/pooled_match). It takes theRulesEngineInterfacein its ctor (AbstractStrategy::__construct) and reads only the keys it cares about fromrule.rewards.
3.1.1 Section-evaluator model
Section titled “3.1.1 Section-evaluator model”Evaluation has two stages and short-circuits hard:
RuleMatcher::evaluate_admission(rule, cart_context) -> { pass, trace } 1. WhoEvaluator::evaluate (rule.who, cart_context) 2. WhereEvaluator::evaluate (rule.where, cart_context) 3. WhenEvaluator::evaluate (rule.when, cart_context) 4. TriggerEvaluator::evaluate (rule.trigger, cart_context) — spend / total qty / line-count / weight / coupon_ids / wc_coupon_stacking / usage_limits
UnifiedStrategy::evaluate(rule, cart, context) 5. trigger pool-stack gate (rule.trigger.pools) — every trigger pool must reach its qty floor 6. reward pool (rule.rewards.reward_target) — RuleMatcher::is_items_section_match per cart line (resolves `target: "same_as_trigger"`)flowchart TB Start([cart context]) --> Who[WhoEvaluator] --> Where[WhereEvaluator] --> When[WhenEvaluator] --> Trig[TriggerEvaluator] --> AdmitOK{admission<br/>passes?} AdmitOK -- no --> Skip([skip rule]) AdmitOK -- yes --> TPool[trigger pool-stack gate<br/>rule.trigger.pools] TPool --> TrigQ{every pool<br/>satisfied?} TrigQ -- no --> Skip TrigQ -- yes --> RPool[reward pool per cart line<br/>rule.rewards.reward_target] RPool --> Allocate[UnifiedStrategy on rule.rewards.kind:<br/>tiered · bulk · pooled_match] Allocate --> Result([DiscountResult])Each section evaluator inlines the field-by-field comparisons it needs — there is no central switch ($field) dispatch. The reward target same_as_trigger resolves to the union of the trigger pools’ selectors once, in RuleMatcher::resolve_pool_match_path(), so the selector evaluator never reaches across buckets. A rule with no trigger pools and a cart / none / same_as_trigger reward target matches every line.
3.2 Engine-level global gates — short-circuits
Section titled “3.2 Engine-level global gates — short-circuits”These run once per evaluate_cart() call, before any rule, in RuleEligibilityGate::global_gate_failure() (includes/Engine/RuleEligibilityGate.php). If one fires, the engine returns []. Per-rule decisions are handled by the section evaluators (§3.2.1).
| # | Gate | Setting | What it does |
|---|---|---|---|
| 1 | WC-coupon kill-switch | wc_coupon_kill_switch | When it is 'disable_dino_when_wc_coupon_applied' AND CartCoupons::partition_applied_coupons() reports any non-Dino coupon in the cart, the engine returns [] and records a kill_switch_fired trace. |
| 2 | Coexistence yield | coexistence_mode | When it is 'yield' and the cart carries a native WC coupon or a known competing discount plugin is active (ConflictDetector::is_incompatible_plugin_active()), no Dino discount applies. 'warn' (default) and 'take_precedence' do not suppress. |
| 3 | Stacking mode | stacking_mode | StackingMode::DISABLE_ALL ('disable_all') returns [] — a “freeze all discounts” escape hatch without deactivating rules one-by-one. The other modes (all_additive, first_match, best_only) shape how matched results combine. |
Coupon interaction at the rule level lives on the trigger section:
trigger.wc_coupon_stacking: "allow" | "block"— whether this rule still applies when a WC coupon is on the cart. The sanitiser defaults it to"block"for automatic rules and"allow"for coupon-tied rules (includes/Engine/RuleSanitizer.php:556). A WC coupon marked individual use blocks the rule either way (includes/Engine/Evaluators/TriggerEvaluator.php:298).trigger.coupon_ids: string[]— fire only when one of these Dino coupons is applied. Each ID is acoupon_idinwp_dino_coupons.
See the coupon-side design at coupon-entity-rebuild-2026-05-16/DESIGN.md for the full Coupon contract.
3.2.1 Per-rule gates — the section evaluators
Section titled “3.2.1 Per-rule gates — the section evaluators”Once the engine is past the global gates, each rule is tested by
RuleMatcher::evaluate_admission(). The cart-level section evaluators run in
fixed order and short-circuit on first failure; every section is recorded in
the returned trace, with sections after the failing one marked
not_evaluated:
| Evaluator | Bucket | Reads |
|---|---|---|
WhoEvaluator | rule.who | roles, logged-in state, first-time customer, min orders, total spent, days since last order |
WhereEvaluator | rule.where | shipping zones and cart currency (match: "any" with a list of values) |
WhenEvaluator | rule.when | schedule.start / schedule.end, weekdays, time of day (all evaluated in wp_timezone()) |
TriggerEvaluator | rule.trigger | spend / total qty / line-count / weight thresholds; coupon_ids; wc_coupon_stacking; usage_limits (total via UsageTracker, per customer via PerCustomerUsageTracker) |
trigger pool-stack gate (in AbstractStrategy) | rule.trigger.pools | each pool’s selector (category / tag / attribute / SKU, or any), its matching mode and its qty floor |
reward pool (RuleMatcher::is_items_section_match) | rule.rewards.reward_target | target: "cart" | "same_as_trigger" | "pools" | "none"; for pools, the same selector shape as above |
Cardinality split:
- Cart-level evaluators run per rule per request — one short-circuit chain per admission decision.
- Pool matching runs per rule per cart line. Selector verdicts are memoised per product identity and selector for the evaluation pass, so rules that share selectors don’t repeat the work.
- The global gates run once per request.
If you’re debugging “why didn’t my rule apply?”, the admission trace is the
fast path — the admin cart preview shows it per section. A global gate would
have stopped the whole engine, so a rule that reaches the matcher and is
rejected is always one of the per-rule section evaluators.
3.2.2 Rules are evaluated in parallel against the undiscounted cart
Section titled “3.2.2 Rules are evaluated in parallel against the undiscounted cart”RulesEngine::evaluate_cart() builds the section-shape CartContext once (includes/Engine/RulesEngine.php:1045–1052) before the per-rule loop. Every rule’s admission check (evaluate_admission → TriggerEvaluator) and every Strategy’s evaluate() reads the same context: the undiscounted cart subtotal, qty, line count, and weight. The engine never re-derives CartContext between rules, and a discount produced by an earlier rule does not mutate the values the next rule sees.
Practically: thresholds in trigger.spend_threshold, trigger.cart_total_qty_threshold, trigger.cart_line_count, and trigger.cart_weight compare against the original cart state, not against a running post-discount subtotal.
Worked example — spend threshold + flat discount
Section titled “Worked example — spend threshold + flat discount”| Rule | Type | Effect |
|---|---|---|
| A | Tiered, threshold 0, percent 30 | Always fires; applies 30% off the cart subtotal. |
| B | Tiered, threshold 0, percent 5, with trigger.spend_threshold = { gte 80 } | Fires only when the cart subtotal is at least £80. |
Cart: £100.
- Sequential interpretation (what some merchants assume): Rule A → cart becomes £70 → Rule B’s £80 threshold no longer met → Rule B silently drops. Final discount: £30.
- Parallel interpretation (what the engine actually does): both rules see £100. Rule B’s £80 threshold is met. Both apply. Final discount (under
stacking_mode: "all_additive"): £30 + £5 = £35.
The engine’s behaviour is parallel. Both rules fire because both read the same hoisted CartContext, whose cart total was fixed at £100.00 before the loop started.
Worked example — qty threshold + free-item reward
Section titled “Worked example — qty threshold + free-item reward”| Rule | Type | Effect |
|---|---|---|
| A | Pooled match, “buy 2 shirts, get 1 free” | Makes one shirt line free at qty 3. |
| B | Tiered, threshold 0, percent 10, with trigger.cart_total_qty_threshold = { gte 4 } | Fires only when total qty is at least 4. |
Cart: 3 items.
The engine sees cart_qty = 3 in CartContext. Rule A’s free-item reward is a Strategy output (a DiscountResult), not a mutation of cart_qty — so Rule B’s gte 4 check still reads 3 and fails. Rule B doesn’t apply. (This is the right answer, but it is also a consequence of the same parallel-evaluation contract: no rule’s reward feeds back into another rule’s admission.)
Why parallel and not sequential
Section titled “Why parallel and not sequential”A sequential / fixed-point evaluator would require choosing a rule order, re-deriving CartContext per rule, and either (a) iterating to convergence on cyclic dependencies or (b) accepting that final discount totals depend on the merchant’s table-row ordering. Parallel evaluation has one canonical answer per cart and avoids both pitfalls.
The trade-off — surfaced in the merchant UI by TriggerSection’s inline hint under the spend threshold — is that “spend over £X” reads as “subtotal at the door, before any other rule has fired.”
Where the contract is enforced
Section titled “Where the contract is enforced”RulesEngine::evaluate_cart()buildsCartContextonce:includes/Engine/RulesEngine.php:1045–1052(look for the inline comment “Build the section-shape CartContext ONCE per evaluate_cart call”).TriggerEvaluator::evaluate()reads onlyCartContextaccessors ($ctx->cart_total(),cart_qty(),cart_line_count(),cart_weight_g()):includes/Engine/Evaluators/TriggerEvaluator.php:104–245. There is no path that reads a “post-discount” total.- Regression test:
tests/Unit/RulesEngine/ParallelEvaluation_Test.phppins the contract — aspend_thresholdrule still admits on a cart whose other matched rules would (if applied sequentially) discount it below the threshold.
If you find yourself needing sequential semantics — “this rule should only fire when no other rule fired against the same items” — use the rule’s Exclusive flag or the store-wide stacking_mode (first_match / best_only), or model it explicitly via trigger.coupon_ids, rather than reaching for a fixed-point evaluator.
3.3 Strategies and the AppliesDiscount trait
Section titled “3.3 Strategies and the AppliesDiscount trait”Discount calculation is handled by a single Strategy class, UnifiedStrategy (includes/Engine/Strategy/UnifiedStrategy.php, extending AbstractStrategy). RulesEngine::get_strategy() returns the same UnifiedStrategy for every built-in rewards.kind (DiscountType::ALL = tiered, bulk, pooled_match); UnifiedStrategy::evaluate() (line 88) then dispatches internally on rule.rewards.kind into one of three allocator modes, reading only the keys it cares about from rule.rewards:
cart_level(rewards.kind === "tiered"→evaluate_cart_level()) — ladder of{threshold, reward}tiers, gated byrewards.metric: "spend" | "qty".group_prorated(rewards.kind === "bulk"→evaluate_group_prorated()) — per-item quantity bands, prorated across the matched group with a last-item remainder.pooled_match(rewards.kind === "pooled_match"→evaluate_pooled_match()) — the pool-stack BOGO / mix-and-match shape: forms bundles across the rule’s pools and applies the reward per bundle. The retiredx_for_yandmix_matchkinds are rejected by the schema (400 on publish).
Discount application to the cart is a trait, not a strategy:
includes/Cart/Traits/AppliesDiscount.php provides apply_cart_discount( $cart )
and is used only by CartCoupons (see includes/Cart/CartCoupons.php:86),
which registers it on woocommerce_before_calculate_totals at priority 20
(line 460). Pulling this into a trait keeps CartCoupons focused on
virtual-coupon bookkeeping while allowing the evaluation loop itself to be
unit-tested in isolation.
3.4 Virtual coupon partitioning
Section titled “3.4 Virtual coupon partitioning”Dino never persists a shop_coupon post. Instead:
- After evaluation,
CartCouponsmints a codedino_dd_<rule_id>(prefix constantCartCoupons::COUPON_PREFIXatincludes/Cart/CartCoupons.php:99). VirtualCouponFactory::virtual_coupon_data()hookswoocommerce_get_shop_coupon_data(priority 10) and materialises the coupon from the in-memory$pending_discountsmap keyed by code.CartCoupons::is_dino_coupon()(includes/Cart/CartCoupons.php:760) andCartCoupons::partition_applied_coupons()(line 1021, delegating toAppliedCouponPartitioner) split the cart’s applied coupons into{ wc: [...], dino: [...], unknown: [...] }. The kill-switch and the coexistence gate both consume this partition.
This is what “virtual coupon partitioning” refers to: the engine treats
dino_dd_* codes as engine output, and any other code as external input that
may or may not trigger the kill-switch depending on settings.
3.5 Persistent coupon codes (real, not virtual)
Section titled “3.5 Persistent coupon codes (real, not virtual)”Separate from the dino_dd_* auto-applied virtual coupons, the plugin also
supports real codes a shopper types into the cart — single codes,
bulk single-use code pools, and URL-token codes. This is a distinct
code path that sits in front of the virtual-coupon factory:
| Component | Role | File |
|---|---|---|
CouponRepository | The coupon entity: one row per coupon in wp_dino_coupons, with its activation kind (single_code, url_token or bulk). | includes/Cart/CouponRepository.php |
CouponManager | Static utility for the bulk code pool: generation, lookup, redemption bookkeeping, CSV export. Owns wp_dino_bulk_codes; generation batches are logged to wp_dino_coupon_generation_log. (CouponCodeManager is a back-compat alias.) | includes/Cart/CouponManager.php |
CouponCodeInterceptor | Hooks woocommerce_get_shop_coupon_data at priority 9 by default — one priority before the virtual-coupon factory (priority 10); the store-wide coupon-precedence setting can move it to 100 so WC’s own lookup goes first. When a shopper enters a code, it resolves the code via CouponResolver, confirms the owning rule is active, and either injects a virtual coupon into the existing CartCoupons pipeline OR falls through on a collision with a native WC shop_coupon. | includes/Cart/CouponCodeInterceptor.php |
CouponCodesController | REST surface for the admin UI to manage coupons and code pools. | includes/API/CouponCodesController.php |
Key properties:
- Collision guard. If a native
shop_couponpost exists with the same code, the interceptor falls through to WooCommerce and records the code in thedino_coupon_collisions_pendingtransient so the merchant sees a warning on their next admin visit. Dino never overrides an existing WC coupon. - Same application pipeline. Once resolved, a persistent code ultimately
flows through the same
CartCouponsvirtual-coupon machinery as an auto-applied rule. The discount calculation is identical. - No
shop_couponposts are ever created. Even for persistent codes — they live inwp_dino_coupons/wp_dino_bulk_codes, notwp_posts.
4. Storage, caching, and invalidation
Section titled “4. Storage, caching, and invalidation”4.1 wp_options rows
Section titled “4.1 wp_options rows”All option keys are constants on includes/Settings/OptionKeys.php. The main ones:
| Key | Purpose | Written by | Read by |
|---|---|---|---|
dino_discounts_active_rules | The published rules, stored as a node tree (promotion nodes + rule nodes) | RuleStore::persist_active (called by RulesController::save_rules, RuleSnapshots::restore_snapshot, ResetManager, DemoStoreSeeder), Migrations steps | RulesEngine (object cache first, get_option on miss), RuleReader |
dino_discounts_draft | The admin editor’s autosaved draft | RuleStore::persist_draft (PUT /rules/draft) | Admin editor only |
dino_discounts_global_settings | Plugin settings (kill-switch, coexistence mode, stacking mode, zones, etc.) | SettingsController, ZonesController, Migrations | RulesEngine::get_global_settings, CartCoupons, numerous readers |
dino_discounts_message_settings | Store-wide Message Settings (where nudges, labels and notices show) | MessageSettings (includes/Settings/MessageSettings.php:315) | Storefront messaging |
dino_discounts_version | Currently-installed schema version | Migrations::maybe_upgrade step-by-step | Migrations::maybe_upgrade gate |
dino_discounts_rules_version | Counter bumped on every cache invalidation | RulesEngine::invalidate_caches | build_cart_fingerprint (so a rule edit invalidates the 60s eval transient) |
dino_discounts_cart_scenarios | Saved cart scenarios for the admin cart preview (defaults + custom) | CartScenariosController (defaults from CartScenarioDefaults) | CartScenariosController |
dino_discounts_first_publish_ts | Unix timestamp of the merchant’s first publish (onboarding signal) | RulesController::save_rules (once, line 913) | Admin (passed to the SPA as firstPublishTs), telemetry |
dino_discounts_debug | Developer flag; toggles debug logging | Manually set | RulesEngine (includes/Engine/RulesEngine.php:3059) |
4.2 Custom tables (prefix {wp_prefix}dino_)
Section titled “4.2 Custom tables (prefix {wp_prefix}dino_)”Created idempotently by Migrations::create_custom_tables()
(includes/Migrations.php:1181) with CREATE TABLE IF NOT EXISTS statements.
All nine tables exist even on a fresh install.
| Table | Purpose | Written by | Read by |
|---|---|---|---|
dino_usage_counts | One row per rule — store-wide total count (hot path) | UsageTracker via UsageHookSubscriber on order-status changes (released again on cancel / refund / fail) | UsageTracker::check_usage_limits, pre-warmed each evaluate_cart |
dino_usage_stats | Dimensional aggregates (rule × currency × country × …) | UsageTracker::record_usage_stats | Admin analytics; not read by the engine |
dino_rule_customer_uses | Per-(rule, customer) usage counts | PerCustomerUsageTracker | PerCustomerUsageTracker, for trigger.usage_limits.per_customer |
dino_rule_snapshots | Rule history for undo / audit (pruned to a maximum count) | RuleSnapshots::save_snapshot on every publish | SnapshotsController for the admin History tab; restore_snapshot for undo |
dino_analytics_events | One row per order where a Dino discount applied | AnalyticsTracker::process_order_analytics via background action dino_discounts_track_analytics | AnalyticsController for dashboard aggregates |
dino_coupons | The coupon entity (code, name, status, activation kind) | CouponRepository | CouponCodeInterceptor (via CouponResolver), admin Coupons tab |
dino_bulk_codes | Per-code rows for bulk code pools | CouponManager, CouponCodeInterceptor on redemption | CouponCodeInterceptor, admin code-management UI |
dino_codes_guest_uses | Guest redemptions of bulk codes, keyed by a hash of the billing email | GuestUsageStore | GuestUsageStore |
dino_coupon_generation_log | Audit row per bulk-generation batch | CouponCodeGenerator | Admin code-management UI |
4.3 Caching layers
Section titled “4.3 Caching layers”| Layer | Scope | Invalidation trigger |
|---|---|---|
WordPress object cache (wp_cache_*, group dino_discounts, keys rules and settings) | Per-request; shared across processes if a persistent cache is installed | RulesEngine::invalidate_caches(), wired in Plugin::define_admin_hooks() |
RulesEngine rule tree | Per instance (one per request) | Re-read if invalidate_caches() runs in the same process |
UsageTracker::$usage_counts_cache | Per evaluate_cart pass | N/A — reset per pass |
CartCoupons::$last_fingerprint / $last_discounts | Per request | Cart contents change → fingerprint changes |
Transient dino_eval_<fingerprint> | Cross-request, 60 seconds | Natural TTL; a rules/settings change bumps dino_discounts_rules_version, which changes the fingerprint; dino_discounts_cache_flush action |
Invalidation story for the rule blob. Plugin::define_admin_hooks()
(includes/Plugin.php:189–295) registers RulesEngine::invalidate_caches() on
update_option_dino_discounts_active_rules and
update_option_dino_discounts_global_settings, plus several WC hooks
(save_post_product, woocommerce_update_product, edited_product_cat,
edited_product_tag, woocommerce_after_shipping_zone_object_save, …).
invalidate_caches() drops the object-cache entries and bumps
dino_discounts_rules_version, so storefront evaluations after the save see
the new rules on the next request instead of a stale 60-second transient.
dino_discounts_cache_flush also flushes the eval transients
(CartCoupons::flush_eval_cache).
5. Extension points (hooks)
Section titled “5. Extension points (hooks)”Theme and plugin developers extend Dino Discounts via the hooks surface.
The full list is auto-generated — always read it from
hooks.md (regenerate with make docs-hooks).
Where to mount your listeners. Prefer a small companion plugin
(/wp-content/plugins/my-shop-dino-hooks/) that loads on plugins_loaded with
a priority later than Dino Discounts. A child theme’s functions.php works
for trivial filters but is fragile: theme switches silently drop your
customisations. Never add listeners inside the Dino Discounts plugin folder —
they’ll be wiped on update.
The hooks that matter most when embedding into another theme/plugin:
dino_discounts_before_apply_coupons/dino_discounts_after_apply_coupons(actions) — wrap the per-request application phase.dino_discounts_discount_applied(action) — fires after each virtual coupon is applied; use for attribution or analytics forwarding.dino_discounts_rules_saved/dino_discounts_settings_saved(actions) — fire after admin writes land; use to invalidate external caches or warm CDN purges.dino_discounts_cache_flush(action) — fire it to flush Dino’s caches, or hook it to purge your own.dino_discounts_coupon_label(filter) — rewrite the label rendered for a virtual coupon.dino_discounts_evaluated_discounts(filter) — last-mile mutation of the discount results before application (use sparingly; you will see every request, and the array shape is not a stable contract).dino_discounts_strategies(filter) — replace the strategy class for a built-inrewards.kind. It cannot add a kind.dino_discounts_incompatible_plugins/dino_discounts_caching_plugins(filters) — extend theConflictDetectorlists so your own plugin shows up (or doesn’t).
The hook doc is verified against source via
make docs-hooks-check, which also runs as part ofmake qa. If you ship a PR that adds a hook without regenerating the doc, CI fails.
6. Migration and version-skew strategy
Section titled “6. Migration and version-skew strategy”includes/Migrations.php implements an idempotent, version-gated dispatcher:
maybe_upgrade()runs onadmin_init, under an option-row lock so two concurrent admin requests can’t both migrate.- It reads
dino_discounts_version(default1.0.0) and compares against theDINO_DISCOUNTS_VERSIONconstant defined indino-discounts.php. - A stored version below
Migrations::BASELINE_FLOOR(4.59.31) is refused: the older steps have been removed, so the plugin records an admin notice and does not migrate. Fresh installs (1.0.0) are exempt — activation already built the current tables. - Each step is a
{ key, after, run }record (seeincludes/Migrations.php:682–830). Steps run in array order; theafterversion is written todino_discounts_versiononly once the step’sruncallback has completed without an error, so an interrupted upgrade resumes cleanly on the next admin load. - Steps that target a version higher than the currently-installed plugin
(shipped but not-yet-released) are skipped — this prevents
stored_version > DINO_DISCOUNTS_VERSIONfrom ever happening. - Migrations primarily rewrite stored data in place — for example,
consolidating storefront-messaging settings into the store-wide
dino_discounts_message_settingsoption, and stripping the retired per-rule placement keys (cart_label_visible,nudge.position_*,storefront.position) from stored rules, the draft and every snapshot. The engine then only ever reads the current section-shaped form served by/wp-json/dino-discounts/v1/schema.
Version-skew for extenders: because all storefront reads go through the
RulesEngine’s cache + get_option path, downgrading the plugin while
leaving migrated data in place is not supported — there is no realtime
backwards-compat shim. The
rollback runbook is the
documented recovery path if a post-cutover hotfix is needed.
7. Where to look next
Section titled “7. Where to look next”| I want to … | Start here |
|---|---|
| Add a new hook for my theme | hooks.md (auto-generated list) |
| Call the REST API from an external service | rest-api-reference.md |
| Override a shopper-facing template | template-overrides.md |
| Troubleshoot cart/coupon interactions | troubleshooting.md |
| Day-one workflow for agency teams | agency-dev-overview.md |
Appendix: Key design patterns
Section titled “Appendix: Key design patterns”| Pattern | Where | Purpose |
|---|---|---|
| Strategy | includes/Engine/Strategy/* | Discount calculation per rewards.kind; dino_discounts_strategies can replace the class for a built-in kind. |
| Trait composition | includes/Cart/Traits/AppliesDiscount.php | Keeps CartCoupons lean while making the evaluation loop reusable. |
| Dependency injection | CartCoupons(RulesEngineInterface $engine), strategies receive the engine | Unit tests substitute a fake engine; avoids hard coupling. |
| Memoisation | AppliesDiscount::resolve_eval_context_and_cache() fingerprint memo + transient | One engine pass per distinct cart state. |
| Object-cache read | RuleSnapshot::load() (wp_cache_get → get_option) | One DB read per request, often zero when the object cache is warm. |
| Single write choke-point | RuleStore::persist_active() / persist_draft() | Every runtime rule write is sanitised in one place. |
| Observer | dino_discounts_* hooks | Extensibility without code modification. |
| Virtual proxy | CartCoupons + VirtualCouponFactory | Participate in WC’s coupon system with no DB entries. |
| Schema-first contract | RuleSchemaController + per-section Engine/Schema/* fragments | JSON Schema is the authoritative wire shape — see /wp-json/dino-discounts/v1/schema. |
| Section evaluator | RuleMatcher::evaluate_admission() ANDs WhoEvaluator / WhereEvaluator / WhenEvaluator / TriggerEvaluator | One evaluator per section, each reads only its own bucket. |
| Version-gated migration | Migrations::maybe_upgrade() | Idempotent, resume-safe upgrades. |