Skip to content

Agency Dev Overview

{/* AUTO-GENERATED from ../docs/agency-dev-overview.md by scripts/sync-dev-docs.mjs — do not edit by hand. */}

A 30-minute orientation for an agency PHP/JS dev landing in this repo for the first time, or an integrator deciding whether to extend rather than fork. A map, not a tutorial — every section links to the deep doc.

Data-model rebuild — in flight. This doc describes the end-state section-shaped rule 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 sibling map) has merged. The retired surface (targeting_tree, RuleFields::allowed_input_fields(), TargetingEvaluator’s recursive switch, top-level type/priority, the dino_coupon_applied targeting leaf, and the v4.20.0 engine cases for fields with no UI) is enumerated in DESIGN §8.

Audience: agency dev, plugin integrator, senior WP engineer. Not audience: merchants (see merchant-guide.md), plugin maintainers (see architecture.md).

Companion doc: architecture.mdhow the plugin works internally. This doc — what you need to know to integrate it.


  1. What it is, architecturally
  2. Quick-start on a client site
  3. Core concepts
  4. Extension points
  5. Common scenarios — and the one you shouldn’t do
  6. Pitfalls you’ll hit
  7. Testing conventions
  8. Known constraints
  9. Where to ask for help

A WordPress plugin, WooCommerce-aware, backed by a rule engine with section-shaped rules (Who / When / Where / Trigger / Rewards + Messaging chrome) evaluated by per-section AND-composed evaluators. Rules live in wp_options (no custom tables for active rule state). The wire contract is the JSON Schema at /wp-json/dino-discounts/v1/schema. Evaluation runs once per request via woocommerce_cart_calculate_fees, memoised by cart fingerprint. Discounts are applied as virtual WC coupons — transient, no shop_coupon post, no DB write at runtime.

Three surfaces: storefront (Frontend.php, template overrides, mini-cart payload), admin (React app in src/), REST (30+ admin routes under /wp-json/dino-discounts/v1/ — authoritative count in rest-api-reference.md, drift-gated separately).

See architecture.md for the component graph and sequence diagram.


Terminal window
# WP-CLI (preferred for client sites)
wp plugin install https://…/dino-discounts.zip --activate
wp plugin list | grep dino-discounts # verify active
# Or: upload the ZIP from ./build-zip.sh via Plugins → Add New

Verify the install worked:

  1. WooCommerce → Dino Discounts menu appears (requires manage_woocommerce).
  2. Create a single % Off rule with a trivial trigger.spend_threshold: { currency: "GBP", operator: "gte", value: 100 } (or your store currency).
  3. Add a product to cart with subtotal ≥ 1.00: discount line shows under the cart subtotal.

Composer-managed sites (Bedrock, wpackagist, private repo):

{
"require": {
"cmc-ltd/dino-discounts": "4.16.29"
}
}

Pin by exact version. Don’t use ^4.16 — the plugin self-bumps patch on every release-deploy-local.sh run, so “latest tag” is not a stable contract for client sites until WP.org review lands. See version-convention.md for the full versioning story. For private-repo installs, point Composer at this repo and prefer the built ZIP from ./build-zip.sh over composer install of the source (the ZIP excludes dev files per .distignore).

Multisite: rules, settings, usage counts, and performance logs are per-site — no shared network state. Uninstall data-drop is opt-in per site (WooCommerce → Dino Discounts → Settings → Delete data on uninstall). See README.md § Multisite.

Requirements: WP 6.2+, WC 7.0+, PHP 7.4+, Node 18+ (dev only). HPOS — compatible (HPOS-aware cart reads).


A rule is a JSON document stored in wp_options under dino_discounts_active_rules. The wire contract is the JSON Schema served live at GET /wp-json/dino-discounts/v1/schema — codegen, validation, and integration tests should target the endpoint rather than scrape examples. Worked examples in section-shaped form: docs/examples/dino-discounts-example-rules.json. Field IDs (dotted storage paths, e.g. who.roles, trigger.eligible_items.categories) are centralised in src/contracts/recipeFieldEnums.js. Per-section schema fragments + evaluator inputs: includes/Engine/Schema/. Sanitisation + cross-section invariants: includes/Engine/RuleSanitizer.php. Hard cap: 200 rules total.

Three discount kinds (DiscountType::ALL), all dispatched through the single UnifiedStrategy on rewards.kind:

rewards.kindPurpose
tieredSpend / qty thresholds (£50 = 5% off, £100 = 10% off)
bulkPer-line volume pricing
pooled_matchPool-stack BOGO and mix-and-match — e.g. “buy 2 get 1 free”, “any 3 from this category for £X” (folds in the retired x_for_y + mix_match kinds)

The pooled_match kind covers what used to be two separate kinds: x_for_y and mix_match were healed to pooled_match by Migrations::step_4_51_0 in 4.51.0. The reward the customer actually gets (percent_off, fixed_amount_off, free_shipping, free_item, cheapest) is a separate axis — DiscountType::ALL_REWARDS, five reward types — chosen per tier/pool within a kind.

Section schema (replaces the targeting tree)

Section titled “Section schema (replaces the targeting tree)”

Every rule has the same six section buckets — who, when, where, trigger, rewards, messaging — each a flat struct. Empty sections serialise as {}; null fields are unconstrained. AND-only across sections, OR-of-values within a multi-value field, NOT only via dedicated exclude toggles (who.roles.match: "any" \| "none", where.zones.match: "any" \| "none", trigger.eligible_items.exclude_on_sale). The historical recursive AND/OR/NOT/NAND/NOR composition is retired — see DESIGN §1.

Two engine entry points, mirroring the wizard’s per-section structure:

  • RuleMatcher::evaluate_admission(rule, cart_ctx) — AND-chains WhoEvaluator::evaluate()WhereEvaluator::evaluate()WhenEvaluator::evaluate()TriggerEvaluator::evaluate(), short-circuiting on first failure.
  • RuleMatcher::evaluate_item(rule, item_ctx) — calls the per-item evaluate_eligible_items twice: once with rule.trigger.eligible_items (admission), once with the resolved reward pool (rule.rewards.eligible_items, falling back to the trigger pool when mode === "same_as_trigger").

Per-section schema fragments live in includes/Engine/Schema/. Section evaluators (WhoEvaluator, WhereEvaluator, WhenEvaluator, TriggerEvaluator) are live in includes/Engine/Evaluators/; RuleMatcher is in includes/Engine/. Strategy dispatch: rule.rewards.kind routes through UnifiedStrategy — there is no top-level type discriminator and no per-kind strategy subclass.

Schedule / zone / currency / role gates are now sections of the rule, not a separate “pre-tree” layer; the cart-level section evaluators above run them in fixed order. See §6 for the zone-context pitfall — still relevant.

When a rule fires, CartCoupons injects a coupon code dino_dd_{rule_id} into the cart. No DB row — data is served on-the-fly via the woocommerce_get_shop_coupon_data filter, and merchants never see the dino_dd_* namespace (display is labelled from cart_label + dino_discounts_coupon_label). Deep dive: architecture.md § 3.4. The proxy-code flow (merchant-entered codes mapping to virtual codes, session hydration) is a separate concern — see §6.

The two engine-level Readers — short-circuit gates

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

The engine has two engine-level short-circuit gates that decide whether any rule even runs: a global kill-switch keyed on wc_coupon_kill_switch, and a global stacking_mode. The third and fourth historical gates (wc_coupon_applied and dino_coupon_applied targeting leaves) are gone — their per-rule responsibilities are now named fields on the trigger section. Full table with file:line references: architecture.md § 3.2.

What matters for integrators:

  • WC_Cart::get_applied_coupons() mixes three populations — WC-native codes (real shop_coupon posts), Dino coupon codes (merchant-entered, e.g. “BLUEMONDAY”), and Dino virtual dino_dd_* codes. Use CartCoupons::partition_applied_coupons() rather than parsing the list yourself.
  • Per-rule “skip this rule if a WC coupon is applied” is trigger.wc_coupon_stacking: "block" | "allow". Default is "block" for automatic rules; "allow" for coupon-tied rules when trigger.coupon_ids is non-empty (sanitiser sets this, RuleSanitizer::sanitize_trigger:353). The trigger-gate evaluator reads it directly — no special operator, no targeting leaf.
  • Per-rule “fire only when this coupon is applied” is trigger.coupon_ids: string[] — a list of FKs into the Coupon entity. OR-of-values. Customer-input-to-coupon-id resolution lives in the Coupon entity (see coupon-rule-linking.md).

Recipes are UI-only — architectural rule

Section titled “Recipes are UI-only — architectural rule”

In the admin UI, merchants pick a recipe (% Off Order, Spend & Save, Volume, BOGO, etc.). Recipes are defined in src/components/recipes/recipeDefinitions.js and shape which fields the admin form exposes — nothing else. They pre-fill engineType, defaults, and a hidden field set.

The engine never branches on rule.recipe. Recipes do not persist on the rule at all — they’re wizard-only ephemeral state (DESIGN §2). Two rules with identical section buckets evaluate identically regardless of which recipe spawned them.

This matters because: if you find yourself wanting engine logic for “the BOGO recipe specifically,” you are wrong. Express it via the section buckets + the appropriate rewards.kind, or raise a new field via RuleSanitizer + the relevant section evaluator. See §5 below.


SurfaceReference
PHP actions + filters (11 actions, 18 filters)hooks.md — auto-generated; drift-gated in make qa
REST API (30+ admin routes)rest-api-reference.md
Store API extension (wc/store/v1/cart, extensions.dino-discounts)rest-api-reference.md
Template overrides (theme yourtheme/dino-discounts/*)template-overrides.md
JS events (dino-discounts:updated on document) + payload (window.dinoDiscountsMiniCart)../README.md#developer-api

High-value filters agencies reach for first:

  • dino_discounts_coupon_label — rewrite the display label on the applied-coupon line.
  • dino_discounts_evaluated_discounts — last-chance filter on the array of discount results before they’re applied.
  • dino_discounts_strategies — map an additional rewards.kind to a custom strategy class (rare — the three built-in kinds, all served by UnifiedStrategy, usually cover it).
  • dino_discounts_targeting_result — override/log the targeting decision per rule.
  • dino_discounts_enabled_currencies — surface your multi-currency plugin’s active codes to the admin UI.
  • dino_discounts_nudge_html — restyle the “spend more to unlock X” storefront nudge.

5. Common scenarios — and the one you shouldn’t do

Section titled “5. Common scenarios — and the one you shouldn’t do”

Don’t. Recipes are UI pre-fills (§3). If you want a new discount shape, the real axes of extension are:

  • A new field on the appropriate section (below) — if your condition isn’t already covered by who / when / where / trigger.eligible_items.
  • A new strategy via dino_discounts_strategies filter — if your discount math is genuinely different from the built-in tiered / bulk / pooled_match kinds.

If you catch yourself adding branches to recipeDefinitions.js that the engine needs to honour, stop. You are smuggling logic into the UI layer.

Engine + UI aligned (locked invariant — DESIGN §1). The wizard surface is the contract — if the UI doesn’t expose a combination, the engine doesn’t support it. Adding a “smuggled” engine path that the wizard can’t drive will fail review.

Worked example: a who.has_active_subscription: boolean | null field that fires only for customers with a current WC Subscriptions active subscription.

  1. Schema fragment — add has_active_subscription to includes/Engine/Schema/WhoSchema.php, with a Draft-07 type clause and null permitted.
  2. Section evaluator — extend the who evaluator to read the new field. Short-circuit null ⇒ skip and otherwise compare against $cart_ctx->customer_has_active_subscription.
  3. Sanitiser — add the per-field sanitiser in RuleSanitizer’s who section pass; reject anything that isn’t true, false, or null.
  4. FieldId enum — add HAS_ACTIVE_SUBSCRIPTION: "who.has_active_subscription" to src/contracts/recipeFieldEnums.js and a matching FIELD_TYPES entry (typed metadata for the property-based test generator).
  5. Admin UI — add the control to the Who section in the wizard.
  6. Tests — unit test the section-evaluator branch; property-test the JS state-lens writer; add a fixture-set entry under tests/jest/setup/propertyTestGenerator.js.

Keep the change surgical — do not add recipe-specific plumbing. The field’s home is whichever section it belongs to.

dino_discounts_coupon_label for the cart-line label; WP gettext filter for admin strings; CSS targeting .dd-* classes for visual tweaks. Do not override templates for copy changes — that ties you to the template contract.

✅ Add an analytics event (or webhook) on discount applied

Section titled “✅ Add an analytics event (or webhook) on discount applied”

Pick by cardinality:

HookFiresPayloadUse for
dino_discounts_discount_appliedPer virtual coupon applied$code, $data, $cartPer-discount analytics (GA4 item-level, Segment track)
dino_discounts_after_apply_couponsOnce per cart evaluation$discounts[], $cartWhole-cart analytics, webhooks (queue via Action Scheduler — this action fires on every recalc)

Don’t use dino_discounts_evaluated_discounts for analytics — that filter runs before application and may be called multiple times per request due to cart recalcs.

dino_discounts_enrich_frontend_payload filter lets you attach fields to window.dinoDiscountsMiniCart without template overrides. Useful for headless / Block-theme integrations.

✅ Headless / Block-based checkout (cart-checkout-blocks)

Section titled “✅ Headless / Block-based checkout (cart-checkout-blocks)”

Discount lines flow through the Store API extension under extensions.dino-discounts on /wp-json/wc/store/v1/cart — see rest-api-reference.md § Store API extension. The Checkout Block surfaces these as standard WC fee lines; no extra plumbing needed for the common case. If you need to inject custom UI into the Checkout Block (e.g. “you saved £X on this order”), enqueue a Block script that reads wp.data.select('wc/store/cart') and subscribes to cart updates — the dino-discounts:updated DOM event on document also fires on fragment refreshes.

✅ Add an admin settings panel or React tab

Section titled “✅ Add an admin settings panel or React tab”

The admin UI is a single React app mounted by Admin.php. There is no formal slot/fill API for third-party panels today — the supported extension paths are (a) WordPress admin menus / settings pages outside the Dino Discounts app, or (b) the dino_discounts_settings_saved action to react to a save. If you need a panel inside the app, open a feature request rather than patching src/ — that path will be ripped out by any upstream refactor.


Tax-base divergence (CART-TOTALS-1 lessons)

Section titled “Tax-base divergence (CART-TOTALS-1 lessons)”

Until v4.16.28, percent-discount bases diverged between the Cart Preview (admin) and real storefront checkout on tax-inclusive stores: preview used wc_get_price_to_display() (respects woocommerce_tax_display_cart), storefront used wc_get_price_excluding_tax(). On a UK inc-tax store a 10% rule would show £7.79 in preview and £7.40 at checkout.

Fix (PR #520): AppliesDiscount::apply_cart_discount() now uses wc_get_price_to_display(); VirtualCouponFactory always emits discount_type='fixed_cart' with a pre-computed amount so WC never recomputes against its own ex-tax base.

What this means for you: if you extend AppliesDiscount or hook dino_discounts_evaluated_discounts to adjust amounts, always base percentages on wc_get_price_to_display(), not wc_get_price_excluding_tax(). Regression-test on an inc-tax store with a non-trivial tax rate.

Zones live in the where section (where.zones: { match: "any" | "none", values: [...] }); they’re evaluated by evaluate_where as part of the admission AND-chain. Definition: includes/Engine/ZoneMatcher.php. A rule with where.zones.values = ["eu"] and match: "any" is hard-gated when no cart-context zone falls in that set.

For multi-country stores: define custom zones once in Global Settings → Zones, then pick zones in the rule’s Where section. Country resolves to a zone via ZoneMatcher at evaluation time — the wizard’s country control writes to where.zones.values. Postal code is intentionally dropped (DESIGN §3.3 / locked decision #2).

Country source: resolved from the country_source global setting (default billing_shipping_store). Read in RulesEngine when building the evaluation context, and again in AppliesDiscount for the cart-fingerprint resolution; the strict-mode guard lives in includes/Engine/ZoneMatcher.php. If your client expects billing-first, configure the setting — don’t override the code path.

Repeating §3 because it burns agencies: if your extension logic lives in src/components/recipes/, it will not survive a rule that was created via the “Show Full Options” path or the REST API. Engine logic goes in the engine.

CouponCodeInterceptor::$proxy_codes is in-memory per request; it is hydrated from WC()->session->get('dino_proxy_codes') at the start of each request and written back at the end. If you’re reading proxy-code state in an unrelated context (a scheduled action, a REST callback outside the storefront), hydrate manually or you’ll see an empty array.

The dino_dd_ prefix is reserved for the engine’s own virtual coupons. The save-time path isn’t policed, but VirtualCouponFactory::validate_virtual_coupon() blocks any dino_dd_* code that isn’t in the current request’s pending_discounts — so a merchant-created shop_coupon post with that prefix will silently never validate against a cart. Don’t collide with it.


CommandWhat it runsContainer needed?
make qaUnit tests, lint, PHPStan, JS build, i18n drift, hook-doc driftNo — runs locally in ~45s
vendor/bin/phpunit -c phpunit.unit.xmlPHP unit suite onlyNo
npm run test:unitJS unit suiteNo
make qa-deepUnit + integration + E2EYes — shared Docker container, needs approval
npm run test:integrationPHP integration (WP test suite)Yes
npm run test:e2ePlaywright E2EYes

Unit tests use WP_Mock; integration tests stand up a real WP. Property tests for the rule engine live under tests/Property/. Shopper-facing string conventions: style-guide-shopper-strings.md. Admin snapshot tests: src/components/__tests__/snapshots/.

CI: pre-push lefthook hook runs make qa locally; scheduled CI runs on main (weekdays 07:00 UTC, E2E Mon+Thu). No per-PR hosted CI — see .agents/PRINCIPLES.md.


  • HPOS — compatible. Cart reads use HPOS-aware APIs.
  • WC floor — 7.0+. Anything older has untested Store API shape.
  • PHP floor — 7.4+.
  • Shared local test container — one session at a time. Agency devs running make qa-deep or E2E in parallel will collide. See CLAUDE.md.
  • Proxy-code session caveat — see §6.
  • Rule caps — 200 rules total (enforced in RulesController::MAX_RULES). The historical “tree depth 10 / 200 nodes per tree” guard is gone — sections are flat structs, not recursive, so there’s nothing to pathologically nest.
  • Coupon generation cap — 10,000 codes per bulk-generate request.
  • REST auth — every route is admin-only (manage_woocommerce). No public routes.

  • Active work: task files live one-per-file under .agents/tasks/. Aggregate state is derived on demand by scripts/tasks.sh — there is no central registry (TASK_REGISTRY.md was removed 2026-05-11).
  • Post-launch bug-reporting channels: the definitive channel list + triage SLAs are being defined under task OBS-POST-LAUNCH-BUG-REPORTING — check there for the current answer.
  • First-line symptoms and fixes: troubleshooting.md — “discount didn’t apply”, “wrong amount”, “mini-cart not updating”.
  • Changelog conventions (for PR body): changelog-process.md.

Next read: architecture.md for the component graph and data-flow diagram, then the relevant reference (hooks.md, rest-api-reference.md, or template-overrides.md) for the extension surface you’re touching.