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-04-21 (branch claude/cool-williams-92e9be).

Data-model rebuild — in flight. The §3 engine internals below describe the end-state section-evaluator model locked in .agents/exploration/rule-shape-rebuild-2026-05-16/DESIGN.md. Phase A (live JSON Schema at /wp-json/dino-discounts/v1/schema, FieldId Option-C dotted paths, FIELD_TYPES) has merged; Phases B (sanitiser), D (engine consumers), and the surrounding rewrites are sequenced for pre-launch. The retired tree-shaped surface — targeting_tree, RuleFields::allowed_input_fields(), TargetingEvaluator::switch($field) — is enumerated in DESIGN §8. 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 assets/admin.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/>StarterRuleSeeder — onboarding"]
subgraph API["REST API — namespace dino-discounts/v1"]
RestAPI["RestAPI"]
Rules["RulesController"]
Settings["SettingsController"]
Snapshots["SnapshotsController"]
Preview["PreviewController"]
Perf["PerformanceController"]
Analytics["AnalyticsController"]
Taxonomy["TaxonomyController"]
CouponCodes["CouponCodesController"]
Onboarding["OnboardingController"]
CartScen["CartScenariosController"]
ProdSrch["ProductSearchController"]
end
subgraph Engine["Rule engine — stateless per request"]
RE["RulesEngine"]
RM["RuleMatcher"]
SE["Section evaluators<br/>who / when / where / trigger"]
EI["evaluate_eligible_items<br/>per-item, dual-pool"]
UT["UsageTracker — DB-backed"]
RS["RuleSanitizer"]
Strat["Strategy/*<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, before virtual factory"]
CCM["CouponCodeManager<br/>persistent 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")]
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")]
TblSnapshots[("wp_dino_rule_snapshots")]
TblAnalytics[("wp_dino_analytics_events")]
TblCoupons[("wp_dino_codes")]
TblGenLog[("wp_dino_coupon_generation_log")]
end
React -- "fetch /wp-json/dino-discounts/v1/*" --> RestAPI
Bootstrap --> Plugin
Plugin --> API
Plugin --> Engine
Plugin --> Cart
Plugin --> Diag
Plugin --> Migrations
Plugin --> Seeder
Rules --> RS
Rules --> Snap
Rules -- "update_option" --> OptRules
Settings -- "update_option" --> OptSettings
Onboarding --> Seeder
Seeder -- "update_option" --> OptRules
RE -- "get_option" --> OptRules
RE --> RM
RE --> SE
RE --> EI
RE --> UT
RE --> Strat
UT -- "SELECT/UPDATE" --> TblUsageCounts
UT -- "INSERT/UPDATE" --> TblUsageStats
Snap -- "INSERT/UPDATE" --> TblSnapshots
Snap -- "restore writes" --> OptRules
AT -- "background INSERT" --> TblAnalytics
CCI --> CCM
CCM -- "SELECT/UPDATE" --> TblCoupons
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

Every controller above extends AbstractController (includes/API/AbstractController.php) for its shared check_permission() callback; it is omitted from the graph to keep the router intent legible. RestAPI.php is the registrar; 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, mounts to #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:152). includes/Storefront/* covers templates and the spend-more nudge.
Persistent coupon codesReal (non-virtual) codes a shopper can type at checkoutincludes/Cart/CouponCodeManager.php (code pool CRUD) + includes/Cart/CouponCodeInterceptor.php (priority-9 hook before virtual factory). Backed by wp_dino_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, enqueues a single background 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.
Starter dataOnboarding seeds sample rules on first publishincludes/StarterData/StarterRuleSeeder.php — writes dino_discounts_active_rules on the onboarding flow (4th write path to that option).
MigrationsIdempotent, version-gated data upgrades + custom-table creationincludes/Migrations.php — runs on admin_init. Also owns create_custom_tables() which issues CREATE TABLE IF NOT EXISTS for all six 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 Save
R->>API: POST /dino-discounts/v1/rules
API->>API: RuleSanitizer sanitize_rules
API->>Options: update_option dino_discounts_active_rules
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
CC->>CC: fingerprint cart, check memo
alt cache hit
CC->>WC: reuse prior virtual coupons
else cache miss
CC->>RE: evaluate_cart with cart, total, currency, country
RE->>Options: get_option dino_discounts_active_rules (object-cached)
RE->>RE: apply Readers (kill-switch, stacking mode)
loop each active rule
RE->>RE: RuleMatcher::evaluate_admission (who → where → when → trigger gate)
alt admission passes
RE->>RE: evaluate_eligible_items per item (trigger pool, then reward pool)
RE->>RE: Strategy on rewards.kind, produces DiscountResult
end
end
RE-->>CC: array of DiscountResult
CC->>CC: mint virtual coupon code dino_dd_RULEID
CC->>WC: apply_coupon dino_dd_RULEID
end
WC-->>Shopper: Render totals with discount line

Key properties:

  • Write paths to dino_discounts_active_rules, in order of frequency.
    1. Admin UIRulesController::save_rules() (includes/API/RulesController.php:225), which delegates to RuleSanitizer and then calls update_option('dino_discounts_active_rules', …) at line 270. This is the only path the React app uses.
    2. Snapshot restoreRuleSnapshots::restore_snapshot() at includes/History/RuleSnapshots.php:231 writes the chosen snapshot back into the rules option (for the “undo” flow). The snapshots themselves live in wp_dino_rule_snapshots, not wp_options.
    3. Onboarding / starter dataStarterRuleSeeder::seed() at includes/StarterData/StarterRuleSeeder.php:99 writes the sample-rule set when a merchant accepts the onboarding offer. Runs at most once per site.
    4. Migrations — several steps in includes/Migrations.php (e.g. lines 628, 865, 1014, 1105) rewrite the option in place on version bumps, canonicalising stored rules to the current section-shaped schema. Pre-rebuild migrations canonicalised legacy targeting-leaf aliases; the rebuild’s one-shot migration (DESIGN §10 item 12) rewrites any surviving snapshots into the section-shaped rule. These run once per version step under admin_init.
  • Read-path for rules is get_option only. The RulesEngine constructor (includes/Engine/RulesEngine.php:154) pulls the rule blob via wp_cache_get('rules', …) first and falls back to get_option('dino_discounts_active_rules', …) on a miss (line 161), then caches into the object cache. The resulting array is held on $this->active_rules for the life of the instance. ⚠️ Caveat: the engine does query custom tables during evaluation — UsageTracker reads wp_dino_usage_counts to enforce total-usage caps (see §3.3), and AnalyticsTracker writes wp_dino_analytics_events on order completion. The “no custom tables” rule applies only to the rule blob itself.
  • Two-layer discount memoisation in AppliesDiscount::apply_cart_discount() (includes/Cart/Traits/AppliesDiscount.php:136–163). Both layers key on the same cart fingerprint from build_cart_fingerprint() (includes/Cart/Traits/ManagesCartState.php:190). The fingerprint is an MD5 of cart_hash | subtotal | subtotal_incl_tax | currency | country | applied_real_coupons | u<user_id>. The applied_real_coupons list starts from CartCoupons::partition_applied_coupons() and adds back any Dino code whose activation_type is NOT 'virtual' (i.e. persistent / proxy codes DO contribute to the fingerprint; only dino_dd_* virtual codes are excluded, because they’re re-derived each pass and would cause self-invalidation). User ID 0 for guests, WP user ID for logged-in users.
    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["Section evaluators<br/>evaluate_who · evaluate_where<br/>evaluate_when · evaluate_trigger_gate"]
RM --> EI["evaluate_eligible_items<br/>(parameterised on bucket key)"]
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. Holds active rules, dispatches to RuleMatcherStrategy.
  • includes/Engine/RuleMatcher.php — owns the two engine entry points (DESIGN §4):
    • evaluate_admission(rule, cart_ctx) ANDs the four cart-level section evaluators (whowherewhentrigger_gate) and short-circuits on first failure.
    • evaluate_item(rule, item_ctx) runs the per-item evaluator pair (trigger pool, reward pool). Each section evaluator reads only its own bucket; no cross-section reads inside an evaluator.
  • includes/Engine/Schema/{WhoSchema,WhenSchema,WhereSchema,TriggerSchema,RewardsSchema,EligibleItemsSchema,MessagingSchema}.php — per-section JSON-Schema fragments (Phase A, live). Composed by RuleSchemaController into the wire contract served at /wp-json/dino-discounts/v1/schema. Schema fragments only — the section evaluators are introduced in Phase D and land alongside RuleMatcher (see DESIGN §10 item 4); they are not present in Engine/Schema/ on main today.
  • includes/Engine/UsageTracker.php — store-wide total usage counters (consumed by the trigger-gate evaluator via trigger.usage_limits). Custom-table-backed: reads wp_dino_usage_counts (O(1) per rule) and writes dimensional aggregates to wp_dino_usage_stats on order completion. The constructor warms a per-request cache ($usage_counts_cache) so each rule is fetched at most once per evaluate_cart() pass. Per-user limits were removed in v4.1.0 alongside the move from wp_options to custom tables (see class docblock, line 24).
  • includes/Engine/RuleSanitizer.php — canonicalises one section at a time, then runs the cross-section invariants pass (DESIGN §4.1: tier monotonicity, tier-currency uniformity, tier reachability vs trigger.spend_threshold, schedule.start ≤ schedule.end, bundle-price soft warning). The ONLY gate between REST input and stored JSON; JSON Schema is the structural contract.
  • 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 (no longer dispatched on a top-level type). The earlier per-kind *Strategy subclasses were folded into this one class.

Per the design, evaluation has two entry points and short-circuits hard:

RuleMatcher::evaluate_admission(rule, cart_ctx) -> AdmissionResult
1. evaluate_who (rule.who, cart_ctx)
2. evaluate_where (rule.where, cart_ctx)
3. evaluate_when (rule.when, cart_ctx)
4. evaluate_trigger_gate (rule.trigger, cart_ctx)
— spend / qty / line-count / weight / coupon_ids /
wc_coupon_stacking / usage_limits
RuleMatcher::evaluate_item(rule, item_ctx) -> ItemMatchResult
5. evaluate_eligible_items(rule.trigger.eligible_items, item_ctx)
— trigger qualification
6. evaluate_eligible_items(resolve(rule.rewards.eligible_items, rule.trigger),
item_ctx)
— reward allocation (resolves `mode: "same_as_trigger"`)

The diagram below mirrors the design doc’s §4:

flowchart TB
Start([cart context])
--> Who[evaluate_who]
--> Where[evaluate_where]
--> When[evaluate_when]
--> TrigGate[evaluate_trigger_gate]
--> AdmitOK{admission<br/>passes?}
AdmitOK -- no --> Skip([skip rule])
AdmitOK -- yes --> PerItem[per cart item:]
PerItem --> TPool[evaluate_eligible_items<br/>rule.trigger.eligible_items]
TPool --> TrigQ{qualifies for trigger?}
TrigQ -- no --> Skip
TrigQ -- yes --> RPool[evaluate_eligible_items<br/>resolve(rule.rewards.eligible_items, rule.trigger)]
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 two per-item calls use the same evaluate_eligible_items function with different bucket arguments; the resolve() helper applies the mode: "same_as_trigger" rule once at the call site so the evaluator never reaches across buckets (preserves PR #1351’s no-fork invariant).

3.2 The two engine-level Readers — short-circuit gates

Section titled “3.2 The two engine-level Readers — short-circuit gates”

Terminology: “Readers” in this codebase refers specifically to the two engine-level short-circuits below, which decide whether any rule gets a chance to run at all. Per-rule decisions are handled by the section evaluators inside RuleMatcher::evaluate_admission() (DESIGN §4); the former per-rule “tree gates” are now plain fields on the rule’s trigger section and are listed in §3.2.1.

#GateWhereWhat it does
1Global kill-switchRulesEngine::evaluate_cart() around includes/Engine/RulesEngine.php:330, setting key wc_coupon_kill_switchWhen wc_coupon_kill_switch === '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.
2Stacking modeSame method, just below the kill-switch. Reads stacking_mode from global settings; StackingMode::DISABLE_ALL returns [] immediately.Gives the merchant a “freeze all discounts” escape hatch without deactivating rules one-by-one.

The legacy gates #3 (wc_coupon_applied targeting leaf) and #4 (dino_coupon_applied targeting leaf) have been removed. Their responsibilities live as named fields on the trigger section:

  • The per-rule “skip if a WC coupon is applied” toggle is now trigger.wc_coupon_stacking: "block" | "allow" (default "block" — matches the v4.19.0 default). The trigger-gate evaluator reads it directly.
  • “Fire only when a specific coupon is applied” is now trigger.coupon_ids: string[] — a list of FKs into the Coupon entity. The trigger-gate evaluator resolves applied codes via the Coupon-side resolver and tests for set membership (OR-of-values).
  • “Apply this nudge only when nothing else fired” — the third historical use of dino_coupon_applied — is no longer expressible at all under constraint #3 of the design (“UI surface defines what’s expressible”); the wizard never surfaced it post-A3 and the engine no longer evaluates it.

See DESIGN §3.4 and the coupon-side companion 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 two Readers, each rule is tested by RuleMatcher::evaluate_admission() (DESIGN §4). The cart-level section evaluators run in fixed order and short-circuit on first failure; the first failing evaluator is recorded on $last_fail_reason (line 72) for diagnostics:

EvaluatorBucketReadsFail reason code
evaluate_whorule.whorole match, login state, paying-customer flag, min orders, total spent, days since last orderwho
evaluate_whererule.wherezone allow/deny, currency allow/deny (match: "any" | "none")where
evaluate_whenrule.whenschedule window, ISO weekdays, time-of-day (all evaluated in wp_timezone())when
evaluate_trigger_gaterule.triggerspend / qty / line-count / weight thresholds; coupon_ids; wc_coupon_stacking; usage_limits via UsageTrackertrigger
evaluate_eligible_items (per-item)rule.trigger.eligible_itemscategory / product / tag / SKU / attribute pickers; exclude_on_sale / on_sale_only toggleseligible_items
evaluate_eligible_items (per-item, reward pool)resolve(rule.rewards.eligible_items, rule.trigger)same shape as above; mode: "same_as_trigger" short-circuits to the trigger pooleligible_items

Cardinality split:

  • Cart-level evaluators (evaluate_who / where / when / trigger_gate) run per rule per request — one short-circuit chain per admission decision.
  • Per-item evaluators (evaluate_eligible_items, both rows) run per rule per cart item — the strategy layer calls them once per item against the trigger pool, then again per item against the reward pool (with mode: "same_as_trigger" collapsing the second call to a no-op resolve back to the first pool).
  • The Readers themselves run once per request.

If you’re debugging “why didn’t my rule apply?”, $last_fail_reason is the fast path — the Readers would have short-circuited 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:524-531) before the per-rule loop. Every rule’s admission check (evaluate_admissionevaluate_trigger_gate) 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_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, GBP }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_minor was sealed at £100.00 before the loop started.

Worked example — qty threshold + free-item reward
Section titled “Worked example — qty threshold + free-item reward”
RuleTypeEffect
AX-for-Y, “free shirt at qty ≥ 3”Adds a free product line at qty 3.
BTiered, threshold 0, percent 10, with trigger.cart_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-product 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/qty/line-count/weight gate — 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:524-531 (look for the inline comment “Build the section-shape CartContext ONCE per evaluate_cart call”).
  • TriggerEvaluator::evaluate() reads only CartContext accessors ($ctx->cart_total_minor(), cart_qty(), cart_line_count(), cart_weight_g()): includes/Engine/Evaluators/TriggerEvaluator.php:84-180. 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” — model it explicitly via trigger.coupon_ids (require a coupon code that another rule does not auto-apply) or wc_coupon_stacking: "block" 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; UnifiedStrategy::evaluate() 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 trigger and reward pools and applies the reward per bundle. The retired x_for_y and mix_match kinds were folded into this one (legacy rules healed to pooled_match by Migrations::step_4_51_0 in 4.51.0; see .agents/designs/pooled-match-redesign/README.md).

The earlier TieredStrategy / BulkStrategy / XForYStrategy / MixMatchStrategy subclasses no longer exist — they were collapsed into the three allocator methods above.

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:69). Pulling this into a trait keeps CartCoupons focused on virtual-coupon bookkeeping while allowing the evaluation loop itself to be unit-tested in isolation. Other files that mention AppliesDiscount only reference it for parity (e.g. Storefront/StorefrontFilter.php mirrors its currency/country resolution order).

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:79).
  2. VirtualCouponFactory::virtual_coupon_data() hooks woocommerce_get_shop_coupon_data and materialises the coupon from the in-memory $pending_discounts map keyed by code.
  3. CartCoupons::is_dino_coupon() (includes/Cart/CartCoupons.php:252) and CartCoupons::partition_applied_coupons() (line 303) split the cart’s applied coupons into { dino: [...], wc: [...] }. The kill-switch (Reader #1) and stacking logic 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 per rule, 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
CouponCodeManagerStatic utility: CRUD for the code pool, bulk generation, CSV export, redemption bookkeeping. Owns the wp_dino_codes + wp_dino_coupon_generation_log tables.includes/Cart/CouponCodeManager.php
CouponCodeInterceptorHooks woocommerce_get_shop_coupon_data at priority 9 — one priority before the virtual-coupon factory (priority 10). When a shopper enters a code, it resolves the code against the DB pool, confirms the owning rule is active, and either injects a virtual coupon into the existing CartCoupons pipeline OR falls through silently on a collision with a native WC shop_coupon.includes/Cart/CouponCodeInterceptor.php
CouponCodesControllerREST surface for the admin UI to manage the pool.includes/API/CouponCodesController.php

Key properties:

  • Collision guard. If a native shop_coupon post exists with the same code, the interceptor falls through silently and sets a transient (dino_coupon_collision_*) 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_codes, not wp_posts.

KeyPurposeWritten byRead by
dino_discounts_active_rulesThe rule blob (single JSON-serialised array)RulesController::save_rules, RuleSnapshots::restore_snapshot, StarterRuleSeeder::seed, Migrations::step_*RulesEngine constructor (via object-cache first, get_option on miss)
dino_discounts_global_settingsPlugin settings (kill-switch, stacking mode, zones, etc.)SettingsController, Migrations::step_*RulesEngine::evaluate_cart, CartCoupons, numerous readers
dino_discounts_versionCurrently-installed schema versionMigrations::maybe_upgrade step-by-stepMigrations::maybe_upgrade gate
dino_discounts_cart_scenariosPreview tool’s saved cart scenarios (one blob with defaults + custom)CartScenariosController, CartScenarioDefaultsPreviewController, admin Preview tab
dino_discounts_first_publish_tsUnix timestamp of the merchant’s first publish (onboarding signal)RulesController::save_rules (once)OnboardingController, WelcomePanel
dino_discounts_debugDeveloper flag; toggles debug loggingManually setRulesEngine around includes/Engine/RulesEngine.php:942

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() via dbDelta with CREATE TABLE IF NOT EXISTS. All six tables exist even on a fresh install.

TablePurposeWritten byRead by
dino_usage_countsOne row per rule — store-wide total count (hot path)UsageTracker::increment_usage on order completionUsageTracker::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_snapshotsAppend-only undo/audit log of rule-state changesRuleSnapshots::create_snapshot on every rule saveSnapshotsController 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_codesPersistent coupon-code pool (single codes, bulk codes, URL tokens). Renamed from dino_coupon_codes in v4.27.0.CouponCodeManager CRUD, CouponCodeInterceptor on redemptionCouponCodeInterceptor::virtual_coupon_data at priority 9 (see §3.5)
dino_coupon_generation_logAudit row per bulk-generation batchCouponCodeManager::generate_bulk_codesAdmin code-management UI
LayerScopeInvalidation trigger
WordPress object cache (wp_cache_*, group dino_discounts_rules)Per-request; shared across processes if a persistent cache is installedupdate_option_dino_discounts_active_rules hook in Plugin::define_admin_hooks() flushes it
RulesEngine::$active_rulesPer instance (one per request)N/A — instance dies at end of request
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, or dino_discounts_cache_flush action

Invalidation story for the rule blob. On save, RulesController fires dino_discounts_rules_saved. Plugin::define_admin_hooks() (around includes/Plugin.php:54–98) registers a cache-flush closure on update_option_dino_discounts_active_rules plus several WC hooks (save_post_product, edited_product_cat, woocommerce_shipping_zone_*, …) that invalidate the object-cache entry. The per-request static on the engine is inherently bounded to one request, so storefront evaluations following the save see the new rules on the next request.


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_coupon_label (filter) — rewrite the label rendered for a virtual coupon.
  • dino_discounts_evaluated_discounts (filter) — last-mile mutation of the DiscountResult[] before application (use sparingly; you will see every request).
  • dino_discounts_strategies (filter) — register additional strategies.
  • dino_discounts_targeting_result (filter) — observe targeting decisions for debugging / overrides.
  • 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.
  2. It reads dino_discounts_version (default 1.0.0) and compares against the DINO_DISCOUNTS_VERSION constant defined in dino-discounts.php.
  3. Each step is a { key, after, run } record (see includes/Migrations.php:64–115). 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.
  4. 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.
  5. Migrations primarily rewrite the rule JSON in-place — canonicalising settings keys, and (in the data-model rebuild’s closing step) rewriting any snapshot rows from the old tree shape into the section-shaped contract served by /wp-json/dino-discounts/v1/schema. The engine then only ever reads the section-shaped form (DESIGN constraint #1: no parallel “old format” reader in shipped code).

Version-skew for extenders: because all storefront reads go through the RulesEngine constructor’s cache+get_option path, downgrading the plugin while leaving migrated data in place is not supported across the data-model rebuild boundary — 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
Understand bootstrap orderingarchitecture-bootstrap-notes.md
Troubleshoot cart/coupon interactionstroubleshooting.md
Day-one workflow for agency teamsagency-dev-overview.md (parallel WIP)

PatternWherePurpose
Strategyincludes/Engine/Strategy/*Discount calculation per rule type.
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.
MemoisationCartCoupons::apply_cart_discount() fingerprint mapOne engine pass per distinct cart state per request.
Object-cache readRulesEngine::__construct() (wp_cache_getget_option)One DB read per request, often zero when the object cache is warm.
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 evaluate_who / evaluate_where / evaluate_when / evaluate_trigger_gateOne evaluator per section, each reads only its own bucket — replaces the recursive TargetingEvaluator switch.
Version-gated migrationMigrations::maybe_upgrade()Idempotent, resume-safe upgrades.