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-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 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 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" --> CustomDBEvery controller above extends
AbstractController(includes/API/AbstractController.php) for its sharedcheck_permission()callback; it is omitted from the graph to keep the router intent legible.RestAPI.phpis the registrar;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, mounts to #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:152). includes/Storefront/* covers templates and the spend-more nudge. |
| Persistent coupon codes | Real (non-virtual) codes a shopper can type at checkout | includes/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 background | includes/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 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. |
| Starter data | Onboarding seeds sample rules on first publish | includes/StarterData/StarterRuleSeeder.php — writes dino_discounts_active_rules on the onboarding flow (4th write path to that option). |
| Migrations | Idempotent, version-gated data upgrades + custom-table creation | includes/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 lineKey properties:
- Write paths to
dino_discounts_active_rules, in order of frequency.- Admin UI —
RulesController::save_rules()(includes/API/RulesController.php:225), which delegates toRuleSanitizerand then callsupdate_option('dino_discounts_active_rules', …)at line 270. This is the only path the React app uses. - Snapshot restore —
RuleSnapshots::restore_snapshot()atincludes/History/RuleSnapshots.php:231writes the chosen snapshot back into the rules option (for the “undo” flow). The snapshots themselves live inwp_dino_rule_snapshots, notwp_options. - Onboarding / starter data —
StarterRuleSeeder::seed()atincludes/StarterData/StarterRuleSeeder.php:99writes the sample-rule set when a merchant accepts the onboarding offer. Runs at most once per site. - 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 underadmin_init.
- Admin UI —
- Read-path for rules is
get_optiononly. TheRulesEngineconstructor (includes/Engine/RulesEngine.php:154) pulls the rule blob viawp_cache_get('rules', …)first and falls back toget_option('dino_discounts_active_rules', …)on a miss (line 161), then caches into the object cache. The resulting array is held on$this->active_rulesfor the life of the instance. ⚠️ Caveat: the engine does query custom tables during evaluation —UsageTrackerreadswp_dino_usage_countsto enforce total-usage caps (see §3.3), andAnalyticsTrackerwriteswp_dino_analytics_eventson 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 frombuild_cart_fingerprint()(includes/Cart/Traits/ManagesCartState.php:190). The fingerprint is an MD5 ofcart_hash | subtotal | subtotal_incl_tax | currency | country | applied_real_coupons | u<user_id>. Theapplied_real_couponslist starts fromCartCoupons::partition_applied_coupons()and adds back any Dino code whoseactivation_typeis NOT'virtual'(i.e. persistent / proxy codes DO contribute to the fingerprint; onlydino_dd_*virtual codes are excluded, because they’re re-derived each pass and would cause self-invalidation). User ID0for guests, WP user ID for logged-in users.- 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["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 toRuleMatcher→Strategy.includes/Engine/RuleMatcher.php— owns the two engine entry points (DESIGN §4):evaluate_admission(rule, cart_ctx)ANDs the four cart-level section evaluators (who→where→when→trigger_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 byRuleSchemaControllerinto the wire contract served at/wp-json/dino-discounts/v1/schema. Schema fragments only — the section evaluators are introduced in Phase D and land alongsideRuleMatcher(see DESIGN §10 item 4); they are not present inEngine/Schema/onmaintoday.includes/Engine/UsageTracker.php— store-wide total usage counters (consumed by the trigger-gate evaluator viatrigger.usage_limits). Custom-table-backed: readswp_dino_usage_counts(O(1) per rule) and writes dimensional aggregates towp_dino_usage_statson order completion. The constructor warms a per-request cache ($usage_counts_cache) so each rule is fetched at most once perevaluate_cart()pass. Per-user limits were removed in v4.1.0 alongside the move fromwp_optionsto 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 vstrigger.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 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(no longer dispatched on a top-leveltype). The earlier per-kind*Strategysubclasses were folded into this one class.
3.1.1 Section-evaluator model (end-state)
Section titled “3.1.1 Section-evaluator model (end-state)”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.
| # | Gate | Where | What it does |
|---|---|---|---|
| 1 | Global kill-switch | RulesEngine::evaluate_cart() around includes/Engine/RulesEngine.php:330, setting key wc_coupon_kill_switch | When 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. |
| 2 | Stacking mode | Same 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:
| Evaluator | Bucket | Reads | Fail reason code |
|---|---|---|---|
evaluate_who | rule.who | role match, login state, paying-customer flag, min orders, total spent, days since last order | who |
evaluate_where | rule.where | zone allow/deny, currency allow/deny (match: "any" | "none") | where |
evaluate_when | rule.when | schedule window, ISO weekdays, time-of-day (all evaluated in wp_timezone()) | when |
evaluate_trigger_gate | rule.trigger | spend / qty / line-count / weight thresholds; coupon_ids; wc_coupon_stacking; usage_limits via UsageTracker | trigger |
evaluate_eligible_items (per-item) | rule.trigger.eligible_items | category / product / tag / SKU / attribute pickers; exclude_on_sale / on_sale_only toggles | eligible_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 pool | eligible_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 (withmode: "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_admission → evaluate_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”| 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, 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”| Rule | Type | Effect |
|---|---|---|
| A | X-for-Y, “free shirt at qty ≥ 3” | Adds a free product line at qty 3. |
| B | Tiered, 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.)
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/qty/line-count/weight gate — 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:524-531(look for the inline comment “Build the section-shape CartContext ONCE per evaluate_cart call”).TriggerEvaluator::evaluate()reads onlyCartContextaccessors ($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.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” — 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 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 trigger and reward pools and applies the reward per bundle. The retiredx_for_yandmix_matchkinds were folded into this one (legacy rules healed topooled_matchbyMigrations::step_4_51_0in 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).
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:79). VirtualCouponFactory::virtual_coupon_data()hookswoocommerce_get_shop_coupon_dataand materialises the coupon from the in-memory$pending_discountsmap keyed by code.CartCoupons::is_dino_coupon()(includes/Cart/CartCoupons.php:252) andCartCoupons::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:
| Component | Role | File |
|---|---|---|
CouponCodeManager | Static 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 |
CouponCodeInterceptor | Hooks 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 |
CouponCodesController | REST surface for the admin UI to manage the pool. | includes/API/CouponCodesController.php |
Key properties:
- Collision guard. If a native
shop_couponpost 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
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_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”| Key | Purpose | Written by | Read by |
|---|---|---|---|
dino_discounts_active_rules | The 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_settings | Plugin settings (kill-switch, stacking mode, zones, etc.) | SettingsController, Migrations::step_* | RulesEngine::evaluate_cart, CartCoupons, numerous readers |
dino_discounts_version | Currently-installed schema version | Migrations::maybe_upgrade step-by-step | Migrations::maybe_upgrade gate |
dino_discounts_cart_scenarios | Preview tool’s saved cart scenarios (one blob with defaults + custom) | CartScenariosController, CartScenarioDefaults | PreviewController, admin Preview tab |
dino_discounts_first_publish_ts | Unix timestamp of the merchant’s first publish (onboarding signal) | RulesController::save_rules (once) | OnboardingController, WelcomePanel |
dino_discounts_debug | Developer flag; toggles debug logging | Manually set | RulesEngine 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.
| Table | Purpose | Written by | Read by |
|---|---|---|---|
dino_usage_counts | One row per rule — store-wide total count (hot path) | UsageTracker::increment_usage on order completion | 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_snapshots | Append-only undo/audit log of rule-state changes | RuleSnapshots::create_snapshot on every rule save | 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_codes | Persistent coupon-code pool (single codes, bulk codes, URL tokens). Renamed from dino_coupon_codes in v4.27.0. | CouponCodeManager CRUD, CouponCodeInterceptor on redemption | CouponCodeInterceptor::virtual_coupon_data at priority 9 (see §3.5) |
dino_coupon_generation_log | Audit row per bulk-generation batch | CouponCodeManager::generate_bulk_codes | 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_rules) | Per-request; shared across processes if a persistent cache is installed | update_option_dino_discounts_active_rules hook in Plugin::define_admin_hooks() flushes it |
RulesEngine::$active_rules | Per instance (one per request) | N/A — instance dies at end of request |
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, 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.
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_coupon_label(filter) — rewrite the label rendered for a virtual coupon.dino_discounts_evaluated_discounts(filter) — last-mile mutation of theDiscountResult[]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 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.- It reads
dino_discounts_version(default1.0.0) and compares against theDINO_DISCOUNTS_VERSIONconstant defined indino-discounts.php. - Each step is a
{ key, after, run }record (seeincludes/Migrations.php:64–115). 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 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.
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 |
| Understand bootstrap ordering | architecture-bootstrap-notes.md |
| Troubleshoot cart/coupon interactions | troubleshooting.md |
| Day-one workflow for agency teams | agency-dev-overview.md (parallel WIP) |
Appendix: Key design patterns
Section titled “Appendix: Key design patterns”| Pattern | Where | Purpose |
|---|---|---|
| Strategy | includes/Engine/Strategy/* | Discount calculation per rule type. |
| 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 | CartCoupons::apply_cart_discount() fingerprint map | One engine pass per distinct cart state per request. |
| Object-cache read | RulesEngine::__construct() (wp_cache_get → get_option) | One DB read per request, often zero when the object cache is warm. |
| 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 evaluate_who / evaluate_where / evaluate_when / evaluate_trigger_gate | One evaluator per section, each reads only its own bucket — replaces the recursive TargetingEvaluator switch. |
| Version-gated migration | Migrations::maybe_upgrade() | Idempotent, resume-safe upgrades. |