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.

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 tree-shaped targeting. Rules live in wp_options (no custom tables for active rule state). 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 cart_subtotal >= 1 targeting leaf.
  3. Add a product to cart: 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_rules. Canonical shape: see docs/examples/dino-discounts-example-rules.json. Field IDs are centralised in includes/Engine/RuleFields.php. Sanitisation + field-range validation: includes/Engine/RuleSanitizer.php. Hard caps: 200 rules total, tree depth 10, 200 nodes.

Four discount types, all served by a single UnifiedStrategy that dispatches on rule['type']:

TypeAllocator branchPurpose
tieredcart_levelSpend thresholds (£50 = 5% off, £100 = 10% off)
bulkgroup_proratedPer-line volume pricing
x_for_yset_walk_free_linesBOGO, 3-for-2
mix_matchset_walk_prorated”Any 3 from this category for £X”

Every rule carries a targeting_tree — a recursive AND/OR/NOT/NAND/NOR of leaf conditions. Evaluator: includes/Engine/TargetingEvaluator.php. 30+ built-in leaf field types (cart_subtotal, product_cat, user_role, region, date, wc_coupon_applied, dino_coupon_applied, etc.), plus two legacy aliases (coupon_appliedwc_coupon_applied, dino_campaign_applieddino_coupon_applied) that the v4.18.0 migration rewrites in saved trees. Full list lives in source — grep case ' in TargetingEvaluator.php.

Rule-level gates evaluate before the tree: is_active, schedule windows, zones, currency context. See §6 for the zone-context pitfall.

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 four “Readers” — engine-level gates

Section titled “The four “Readers” — engine-level gates”

The engine has four short-circuit gates that decide whether any rule even runs: a global kill-switch keyed on wc_coupon_kill_switch, a global stacking mode, and two targeting-tree leaves (wc_coupon_applied, dino_coupon_applied) that work per-rule. 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 campaign 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 a targeting-tree condition, not a rule-level flag. Express it as wc_coupon_applied IS_NONE.
  • v4.19.0 migration caveat: a pre-v4.19.0 rule-level wc_coupon_stacking flag no longer exists on RuleMatcher; the migration rewrites surviving rules to the tree form. The global setting was also renamed wc_coupon_stackingwc_coupon_kill_switch. If you’re reading stored rules or snapshots from an older backup, expect both key names.

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. Sanitiser persists it as metadata; matcher/evaluator/strategies ignore it. Two rules with identical targeting_tree + type but different recipe values evaluate identically.

This matters because: if you find yourself wanting engine logic for “the BOGO recipe specifically,” you are wrong. Express it via the standard targeting tree + strategy fields, or raise a new rule field via RuleSanitizer + TargetingEvaluator. See §5 below.


SurfaceReference
PHP actions + filters (10 actions, 15 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 — register a custom discount strategy (rare — usually the four built-ins 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 targeting field (below) — if your condition isn’t already in the built-ins.
  • A new strategy via dino_discounts_strategies filter — if your discount math is genuinely different from tiered / bulk / x-for-y / mix-match.

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

Worked example: cart_has_tag (cart contains a product with tag X).

  1. Evaluator — add a case 'cart_has_tag': branch in TargetingEvaluator::evaluate_leaf(). Read $context['cart'], iterate line items, check wc_get_product( $item['product_id'] )->get_tags() against $leaf['values'].
  2. Sanitiser — register the field in RuleSanitizer so saved rules with this field don’t get stripped. Model it on an existing taxonomy field (product_cat).
  3. Admin UI — add an option to the leaf-field dropdown in src/components/rules/targeting/ (mirror product_cat).
  4. REST taxonomy search — if users need to pick tags by label, extend TaxonomyController to surface tag results, or reuse the existing /taxonomy/search endpoint.
  5. Tests — unit test the evaluator branch in tests/Unit/Engine/TargetingEvaluatorTest.php. Property-test the sanitiser round-trip. Integration test a full cart scenario in tests/Integration/.

Keep the change surgical — do not add recipe-specific plumbing for this field. It’s a standard leaf.

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 are rule-level, not targeting-tree-level. They evaluate before the tree. Definition: includes/Engine/ZoneMatcher.php. A rule with zones=['EU'] is hard-gated before any leaf is touched; a region IS_ANY leaf in the tree won’t compensate if zones are mis-set.

For multi-country stores: define custom zones once in Global Settings → Zones, then pick zones per rule. Don’t hand-roll country matching in the tree — use region IS_ANY { countries } for fine-grained cases inside a zone, but zones for the coarse partition.

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.


  • 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); tree depth 10 and 200 nodes per tree (enforced in RuleSanitizer).
  • Coupon generation cap — 10,000 codes per bulk-generate request.
  • REST auth — every route is admin-only (manage_woocommerce). No public routes.

  • Task registry (active work): .agents/TASK_REGISTRY.md.
  • 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.