Skip to content

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.md first — 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 is RuleSanitizer, and the engine consumers are the section evaluators in includes/Engine/Evaluators/ (§3.2.1). The old tree-shaped surface — targeting_tree and the TargetingEvaluator switch — 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 at docs/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.


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.php is the registrar: it registers 21 controllers on rest_api_init (includes/API/RestAPI.php:77–106), plus TelemetryErrorController when a telemetry endpoint is configured. Only the ones that matter for this page are drawn. Every controller extends AbstractController (includes/API/AbstractController.php) for its shared check_permission() callback. VirtualCart.php, VirtualProduct.php, and CartScenarioDefaults.php are data-shaping helpers rather than route owners.

LayerPurposeEntry points
BootstrapPlugin loader, constants, autoloaddino-discounts.php, includes/Plugin.php
Admin React SPAAll rule authoring UI, settings, analytics, historysrc/index.js, src/App.js, built to compiled/index.js and mounted on #dino-discounts-admin-app (rendered by includes/Admin.php)
REST APISingle source of truth for admin writes and previewsincludes/API/RestAPI.php registers routes on rest_api_init under dino-discounts/v1
Rule enginePer-request evaluation of rules → discount resultsincludes/Engine/RulesEngine.php
StorefrontCart-level application of discounts; Blocks + classicincludes/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 codesReal (non-virtual) codes a shopper can type at checkoutincludes/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 backgroundincludes/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 RESTincludes/API/AnalyticsController.php — aggregates from wp_dino_analytics_events for the admin dashboard.
DiagnosticsPerformance monitor, conflict/caching warnings, structured logsincludes/Diagnostics/PerformanceMonitor.php, includes/ConflictDetector.php, includes/Observability/Logger.php
HistoryRule 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 dataSample rules for onboarding and resetsincludes/StarterData/StarterRuleSeeder.php builds the sample rules; includes/StarterData/ResetManager.php persists them when the admin calls POST /rules/reset-to-seeded.
MigrationsIdempotent, version-gated data upgrades + custom-table creationincludes/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 line

Key properties:

  • Write paths to dino_discounts_active_rules. Every runtime writer goes through RuleStore::persist_active() (includes/Settings/RuleStore.php:85), which re-sanitises every rule and then writes the stored node tree with update_option(…, false).
    1. Admin UI — RulesController::save_rules() (includes/API/RulesController.php:656) sanitises, then calls RuleStore::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 separate dino_discounts_draft option through RuleStore::persist_draft().
    2. Snapshot restore — RuleSnapshots::restore_snapshot() at includes/History/RuleSnapshots.php:245 writes the chosen snapshot back through RuleStore::persist_active() (line 344). The snapshots themselves live in wp_dino_rule_snapshots, not wp_options.
    3. Reset to sample rules — POST /rules/reset-to-seeded runs ResetManager::execute(), which builds rules with StarterRuleSeeder::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 through RuleStore.
    4. Migrations — a few steps in includes/Migrations.php (e.g. currency-scope-backfill, messaging-per-rule-key-retirement) rewrite stored rules in place via update_active_rules_or_throw() (line 930). This is the one path that does not go through RuleStore. Steps run once per version step under admin_init. A pre_update_option guard (RuleWriteGuard, includes/Engine/Schema/RuleWriteGuard.php:99) also asserts the shape of any write to the option.
  • 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_option only. RuleSnapshot::load() (includes/Engine/RuleSnapshot.php:223) reads wp_cache_get('rules', 'dino_discounts') first and falls back to NodeTreeStorage::read_raw_active_rules() (a get_option read) on a miss, then caches into the object cache. ⚠️ Caveat: the engine does query custom tables during evaluation — UsageTracker reads wp_dino_usage_counts and PerCustomerUsageTracker reads wp_dino_rule_customer_uses to enforce usage caps (see §3.2.1), and AnalyticsTracker writes wp_dino_analytics_events after 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 from build_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, and dino_discounts_rules_version. The applied-real-coupons list starts from the wc bucket of CartCoupons::partition_applied_coupons() and adds back any Dino code whose activation_type is NOT 'virtual' (persistent codes DO contribute; only dino_dd_* virtual codes are excluded, because they’re re-derived each pass and would cause self-invalidation). User ID 0 for guests.
    1. Request-scoped: $last_fingerprint / $last_discounts skip the engine on repeated woocommerce_before_calculate_totals passes within the same PHP request.
    2. 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.
  • Virtual coupons are the application mechanism. No row ever appears in wp_posts (shop_coupon) for a Dino discount.

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 to RuleMatcher → 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 by RuleSchemaController (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 in Engine/Evaluators/.
  • includes/Engine/UsageTracker.php — store-wide total usage counters (consumed by the trigger evaluator via trigger.usage_limits.total). Custom-table-backed: reads wp_dino_usage_counts (O(1) per rule) and writes dimensional aggregates to wp_dino_usage_stats. warm_counts() fills a per-request cache ($usage_counts_cache) so each rule is fetched at most once per evaluate_cart() pass.
  • includes/Engine/PerCustomerUsageTracker.php — per-customer counters for trigger.usage_limits.per_customer, in wp_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 calls RuleValidator::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, and when.schedule.start ≤ when.schedule.end. JSON Schema is the structural contract; RuleStore and RuleWriteGuard make sure nothing reaches storage without passing it.
  • includes/Engine/Strategy/* — a single concrete UnifiedStrategy (extending AbstractStrategy, implementing DiscountStrategyInterface) handles every built-in rewards.kind. RulesEngine::get_strategy() maps tiered, bulk, and pooled_match all to the same class; UnifiedStrategy::evaluate() then dispatches internally on rule.rewards.kind into one of three allocator modes (cart_level / group_prorated / pooled_match). It takes the RulesEngineInterface in its ctor (AbstractStrategy::__construct) and reads only the keys it cares about from rule.rewards.

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).

#GateSettingWhat it does
1WC-coupon kill-switchwc_coupon_kill_switchWhen 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.
2Coexistence yieldcoexistence_modeWhen 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.
3Stacking modestacking_modeStackingMode::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 a coupon_id in wp_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:

EvaluatorBucketReads
WhoEvaluatorrule.whoroles, logged-in state, first-time customer, min orders, total spent, days since last order
WhereEvaluatorrule.whereshipping zones and cart currency (match: "any" with a list of values)
WhenEvaluatorrule.whenschedule.start / schedule.end, weekdays, time of day (all evaluated in wp_timezone())
TriggerEvaluatorrule.triggerspend / 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.poolseach 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_targettarget: "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”
RuleTypeEffect
ATiered, threshold 0, percent 30Always fires; applies 30% off the cart subtotal.
BTiered, 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”
RuleTypeEffect
APooled match, “buy 2 shirts, get 1 free”Makes one shirt line free at qty 3.
BTiered, 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.)

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.”

  • RulesEngine::evaluate_cart() builds CartContext once: includes/Engine/RulesEngine.php:1045–1052 (look for the inline comment “Build the section-shape CartContext ONCE per evaluate_cart call”).
  • TriggerEvaluator::evaluate() reads only CartContext accessors ($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.php pins the contract — a spend_threshold rule 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 by rewards.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 retired x_for_y and mix_match kinds 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.

Dino never persists a shop_coupon post. Instead:

  1. After evaluation, CartCoupons mints a code dino_dd_<rule_id> (prefix constant CartCoupons::COUPON_PREFIX at includes/Cart/CartCoupons.php:99).
  2. VirtualCouponFactory::virtual_coupon_data() hooks woocommerce_get_shop_coupon_data (priority 10) and materialises the coupon from the in-memory $pending_discounts map keyed by code.
  3. CartCoupons::is_dino_coupon() (includes/Cart/CartCoupons.php:760) and CartCoupons::partition_applied_coupons() (line 1021, delegating to AppliedCouponPartitioner) 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:

ComponentRoleFile
CouponRepositoryThe coupon entity: one row per coupon in wp_dino_coupons, with its activation kind (single_code, url_token or bulk).includes/Cart/CouponRepository.php
CouponManagerStatic 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
CouponCodeInterceptorHooks 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
CouponCodesControllerREST surface for the admin UI to manage coupons and code pools.includes/API/CouponCodesController.php

Key properties:

  • Collision guard. If a native shop_coupon post exists with the same code, the interceptor falls through to WooCommerce and records the code in the dino_coupon_collisions_pending transient 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 CartCoupons virtual-coupon machinery as an auto-applied rule. The discount calculation is identical.
  • No shop_coupon posts are ever created. Even for persistent codes — they live in wp_dino_coupons / wp_dino_bulk_codes, not wp_posts.

All option keys are constants on includes/Settings/OptionKeys.php. The main ones:

KeyPurposeWritten byRead by
dino_discounts_active_rulesThe 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 stepsRulesEngine (object cache first, get_option on miss), RuleReader
dino_discounts_draftThe admin editor’s autosaved draftRuleStore::persist_draft (PUT /rules/draft)Admin editor only
dino_discounts_global_settingsPlugin settings (kill-switch, coexistence mode, stacking mode, zones, etc.)SettingsController, ZonesController, MigrationsRulesEngine::get_global_settings, CartCoupons, numerous readers
dino_discounts_message_settingsStore-wide Message Settings (where nudges, labels and notices show)MessageSettings (includes/Settings/MessageSettings.php:315)Storefront messaging
dino_discounts_versionCurrently-installed schema versionMigrations::maybe_upgrade step-by-stepMigrations::maybe_upgrade gate
dino_discounts_rules_versionCounter bumped on every cache invalidationRulesEngine::invalidate_cachesbuild_cart_fingerprint (so a rule edit invalidates the 60s eval transient)
dino_discounts_cart_scenariosSaved cart scenarios for the admin cart preview (defaults + custom)CartScenariosController (defaults from CartScenarioDefaults)CartScenariosController
dino_discounts_first_publish_tsUnix 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_debugDeveloper flag; toggles debug loggingManually setRulesEngine (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.

TablePurposeWritten byRead by
dino_usage_countsOne 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_statsDimensional aggregates (rule × currency × country × …)UsageTracker::record_usage_statsAdmin analytics; not read by the engine
dino_rule_customer_usesPer-(rule, customer) usage countsPerCustomerUsageTrackerPerCustomerUsageTracker, for trigger.usage_limits.per_customer
dino_rule_snapshotsRule history for undo / audit (pruned to a maximum count)RuleSnapshots::save_snapshot on every publishSnapshotsController for the admin History tab; restore_snapshot for undo
dino_analytics_eventsOne row per order where a Dino discount appliedAnalyticsTracker::process_order_analytics via background action dino_discounts_track_analyticsAnalyticsController for dashboard aggregates
dino_couponsThe coupon entity (code, name, status, activation kind)CouponRepositoryCouponCodeInterceptor (via CouponResolver), admin Coupons tab
dino_bulk_codesPer-code rows for bulk code poolsCouponManager, CouponCodeInterceptor on redemptionCouponCodeInterceptor, admin code-management UI
dino_codes_guest_usesGuest redemptions of bulk codes, keyed by a hash of the billing emailGuestUsageStoreGuestUsageStore
dino_coupon_generation_logAudit row per bulk-generation batchCouponCodeGeneratorAdmin code-management UI
LayerScopeInvalidation trigger
WordPress object cache (wp_cache_*, group dino_discounts, keys rules and settings)Per-request; shared across processes if a persistent cache is installedRulesEngine::invalidate_caches(), wired in Plugin::define_admin_hooks()
RulesEngine rule treePer instance (one per request)Re-read if invalidate_caches() runs in the same process
UsageTracker::$usage_counts_cachePer evaluate_cart passN/A — reset per pass
CartCoupons::$last_fingerprint / $last_discountsPer requestCart contents change → fingerprint changes
Transient dino_eval_<fingerprint>Cross-request, 60 secondsNatural 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).


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-in rewards.kind. It cannot add a kind.
  • dino_discounts_incompatible_plugins / dino_discounts_caching_plugins (filters) — extend the ConflictDetector lists 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 of make qa. If you ship a PR that adds a hook without regenerating the doc, CI fails.


includes/Migrations.php implements an idempotent, version-gated dispatcher:

  1. maybe_upgrade() runs on admin_init, under an option-row lock so two concurrent admin requests can’t both migrate.
  2. It reads dino_discounts_version (default 1.0.0) and compares against the DINO_DISCOUNTS_VERSION constant defined in dino-discounts.php.
  3. 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.
  4. Each step is a { key, after, run } record (see includes/Migrations.php:682–830). Steps run in array order; the after version is written to dino_discounts_version only once the step’s run callback has completed without an error, so an interrupted upgrade resumes cleanly on the next admin load.
  5. Steps that target a version higher than the currently-installed plugin (shipped but not-yet-released) are skipped — this prevents stored_version > DINO_DISCOUNTS_VERSION from ever happening.
  6. Migrations primarily rewrite stored data in place — for example, consolidating storefront-messaging settings into the store-wide dino_discounts_message_settings option, 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.


I want to …Start here
Add a new hook for my themehooks.md (auto-generated list)
Call the REST API from an external servicerest-api-reference.md
Override a shopper-facing templatetemplate-overrides.md
Troubleshoot cart/coupon interactionstroubleshooting.md
Day-one workflow for agency teamsagency-dev-overview.md

PatternWherePurpose
Strategyincludes/Engine/Strategy/*Discount calculation per rewards.kind; dino_discounts_strategies can replace the class for a built-in kind.
Trait compositionincludes/Cart/Traits/AppliesDiscount.phpKeeps CartCoupons lean while making the evaluation loop reusable.
Dependency injectionCartCoupons(RulesEngineInterface $engine), strategies receive the engineUnit tests substitute a fake engine; avoids hard coupling.
MemoisationAppliesDiscount::resolve_eval_context_and_cache() fingerprint memo + transientOne engine pass per distinct cart state.
Object-cache readRuleSnapshot::load() (wp_cache_get → get_option)One DB read per request, often zero when the object cache is warm.
Single write choke-pointRuleStore::persist_active() / persist_draft()Every runtime rule write is sanitised in one place.
Observerdino_discounts_* hooksExtensibility without code modification.
Virtual proxyCartCoupons + VirtualCouponFactoryParticipate in WC’s coupon system with no DB entries.
Schema-first contractRuleSchemaController + per-section Engine/Schema/* fragmentsJSON Schema is the authoritative wire shape — see /wp-json/dino-discounts/v1/schema.
Section evaluatorRuleMatcher::evaluate_admission() ANDs WhoEvaluator / WhereEvaluator / WhenEvaluator / TriggerEvaluatorOne evaluator per section, each reads only its own bucket.
Version-gated migrationMigrations::maybe_upgrade()Idempotent, resume-safe upgrades.

WooCommerce is a trademark of Automattic, Inc. WordPress is a trademark of the WordPress Foundation. Dino Discounts is not affiliated with or endorsed by Automattic or the WordPress Foundation.

Contact · Report a bug