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.

Section-shaped rules. This doc describes the section-shaped rule model: the live JSON Schema at /wp-json/dino-discounts/v1/schema (dotted FieldId paths, FIELD_TYPES sibling map), the per-section sanitiser, and the RuleMatcher section evaluators. The older recursive targeting tree (targeting_tree, TargetingEvaluator, top-level type/priority, the dino_coupon_applied targeting leaf) no longer exists. architecture.md §3 has the engine internals.

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

Companion doc: architecture.md — how 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 on woocommerce_before_calculate_totals (CartCoupons::apply_cart_discount, priority 20 by default), memoised by cart fingerprint. Discounts are applied as virtual WC coupons — no shop_coupon post is created.

Three surfaces: storefront (Frontend.php, template overrides, mini-cart payload), admin (React app in src/), REST (40+ 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 scripts/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: { operator: "gte", value: 1 } (the value is read in the cart’s 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/dino-discounts": "4.60.2"
}
}

Pin by exact version. Don’t use ^4.60 — 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 scripts/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 → When you delete this plugin → Also erase all Dino Discounts data on delete). See README.md § Multisite.

Requirements: WP 6.5+ (tested up to 7.1), WC 8.8+ (tested up to 10.7), PHP 7.4+. Node 20 for building from source (dev only). HPOS — compatible (declared via FeaturesUtil::declare_compatibility).


A rule is a JSON document stored in wp_options under dino_discounts_active_rules (the option holds a node tree: rules, optionally grouped under promotions). 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.pools.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”

The x_for_y and mix_match kinds are retired: a rule carrying either is rejected on save (400 rule.schema_invalid). Use pooled_match. 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 section buckets — who, when, where, trigger, rewards, messaging, plus metadata — each a flat struct. Null fields are unconstrained. AND-only across sections, OR-of-values within a multi-value field. Multi-value fields such as who.roles, where.zones and where.currency take { match: "any", values: [...] }; there is no exclusion ("none") mode. Trigger item pools (trigger.pools[]) stack conjunctively: every pool must match. There is no recursive AND/OR/NOT composition.

Two evaluation paths, mirroring the rule editor’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.
  • Per cart line, RuleMatcher::is_item_matching_rule(item, rule) matches the line against the trigger pools (rule.trigger.pools[]), and RuleMatcher::is_items_section_match(item, rule) against the reward target (rule.rewards.reward_target; target: "same_as_trigger" points back at the trigger pools).

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

Schedule / zone / currency / role gates are sections of the rule; 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 messaging.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.

Engine-level global gates — short-circuits

Section titled “Engine-level global gates — short-circuits”

The engine has three engine-level short-circuit gates that decide whether any rule even runs: the WC-coupon kill-switch (wc_coupon_kill_switch), coexistence yield (coexistence_mode: "yield"), and stacking_mode: "disable_all". Per-rule coupon gating lives in 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 (the sanitiser sets this default in RuleSanitizer::sanitize_trigger(); the rule editor’s Allow stacking with WooCommerce coupons toggle sets it explicitly). The trigger evaluator reads it directly.
  • Per-rule “fire only when this coupon is applied” is trigger.coupon_ids: string[] — a list of Coupon-entity ids. OR-of-values. Typed-code-to-coupon-id resolution lives on the Coupon side (CouponResolver) (see coupon-rule-linking.md).

Recipes are UI-only — architectural rule

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

In the admin UI, merchants start from a Gallery template (% Off Order, Spend & Save, Volume Discount, BOGOF, etc.). Templates (src/components/recipes/templateData.js) seed the section values; recipes (src/components/recipes/recipeDefinitions.js) decide which fields the admin form surfaces and requires — nothing else.

The engine never branches on the recipe. The only trace on the saved rule is metadata.recipe_intent (the template id), which the editor uses to keep the rule’s type label; the engine does not read it for matching or pricing. Two rules with identical section buckets evaluate identically regardless of which template 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 (see hooks.md for the live count)hooks.md — auto-generated; drift-gated in make qa
REST API (40+ 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 — replace the strategy class behind a built-in rewards.kind (rare — UnifiedStrategy serves all three). It cannot add a kind: the rule schema rejects any other rewards.kind.
  • 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 replacement strategy via the dino_discounts_strategies filter — if your discount math for a built-in tiered / bulk / pooled_match kind is genuinely different. The filter cannot add a kind, and the editor has no UI for one.

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; dino_discounts_storefront_message for the on-page promotional message copy; WP gettext filter for admin strings; CSS targeting .dd-* classes for visual tweaks (see the CSS / Styling Reference for the per-surface class inventory and dequeue-able handles). 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 each discount entry in window.dinoDiscountsMiniCart without template overrides (dino_discounts_discount_payload filters the whole payload). 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. Dino discounts are virtual WC coupons, so the Cart and Checkout Blocks show them with the cart’s other applied coupons; 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 bases cart percentages on 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 WhereEvaluator::evaluate() 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 the Countries & Zones tab, then pick them in the rule’s Customers in specific zones control (Where section), which writes to where.zones.values. The shopper’s country resolves to a zone via ZoneMatcher at evaluation time. Postal code is intentionally dropped (DESIGN §3.3 / locked decision #2).

Country source: resolved from the country_source global setting (default shipping_billing_store — shipping, then billing, then the store base; it was billing_shipping_store before 4.59.62, and a one-time migration moves stores still carrying that old default). Shipping-first matches the address WooCommerce itself uses for taxes and shipping zones, and the one WooCommerce Price Based on Country prices on when its “Price based on” is shipping, so a split billing/shipping shopper is priced and discounted against the same country. Resolved by CountrySourceResolver when CartContextProvider builds the evaluation context; the strict-mode guard lives in includes/Engine/ZoneMatcher.php. If your client expects billing-first, configure the setting — it is still fully supported — 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 Build any discount or the REST API. Engine logic goes in the engine.

The proxy-code map (typed code → dino_dd_* virtual code) is owned by ProxyLedger: a per-request working copy, lazily hydrated from the WC session key dino_proxy_codes and delta-merged back. In a context with no shopper WC session (a scheduled action, a REST callback outside the storefront) there is nothing to hydrate from, so you’ll see an empty map.

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 (or awaiting the request’s first evaluation) — 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 drift, repo guard scriptsNo
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/Unit/Properties/. Shopper-facing string conventions: style-guide-shopper-strings.md. Admin snapshot tests: src/components/__tests__/snapshots/.

CI: the pre-push lefthook hook runs the fast gate locally. The hosted scheduled CI workflows currently run on manual dispatch only (their automatic triggers are disabled).


  • HPOS — compatible. Cart reads use HPOS-aware APIs.
  • WC floor — 8.8+. Stacked-coupon settlement at checkout and per-line allocation on admin orders read woocommerce_coupon_get_items_to_apply, which first fires in 8.8.
  • PHP floor — 7.4+.
  • Shared local test container — the :80 container serves one session at a time; running make qa-deep or E2E against it in parallel will collide. Isolated lanes (LANE=<port>) run in parallel — see docs/process/test-tiers.md.
  • Proxy-code session caveat — see §6.
  • Rule caps — 200 rules total (enforced in RulesController::MAX_RULES). Sections are flat structs, not recursive, so there is no nesting limit to hit.
  • Coupon generation cap — 10,000 codes per bulk-generate request (CouponCodesController::MAX_GENERATE_QUANTITY, on both create and top-up).
  • REST auth — routes require manage_woocommerce, with a few exceptions: POST /demo-store/seed and POST /telemetry-consent need manage_options, POST /bug-report/submit needs a logged-in user with edit_posts (filterable), and POST /compat/{id}/probe is open to storefront visitors (it stores one enumerated probe outcome, validated in the callback).

  • 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).
  • In-product bug reports: the Feedback button in the plugin admin header (shown when bug reporting is configured) opens the Report a bug form.
  • 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.

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

Contact · Report a bug