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.md — how the plugin works internally. This doc — what you need to know to integrate it.
Contents
Section titled “Contents”- What it is, architecturally
- Quick-start on a client site
- Core concepts
- Extension points
- Common scenarios — and the one you shouldn’t do
- Pitfalls you’ll hit
- Testing conventions
- Known constraints
- Where to ask for help
1. What it is, architecturally
Section titled “1. What it is, architecturally”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.
2. Quick-start on a client site
Section titled “2. Quick-start on a client site”# WP-CLI (preferred for client sites)wp plugin install https://…/dino-discounts.zip --activatewp plugin list | grep dino-discounts # verify active
# Or: upload the ZIP from ./build-zip.sh via Plugins → Add NewVerify the install worked:
- WooCommerce → Dino Discounts menu appears (requires
manage_woocommerce). - Create a single % Off rule with a trivial
cart_subtotal >= 1targeting leaf. - 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).
3. Core concepts
Section titled “3. Core concepts”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']:
| Type | Allocator branch | Purpose |
|---|---|---|
tiered | cart_level | Spend thresholds (£50 = 5% off, £100 = 10% off) |
bulk | group_prorated | Per-line volume pricing |
x_for_y | set_walk_free_lines | BOGO, 3-for-2 |
mix_match | set_walk_prorated | ”Any 3 from this category for £X” |
Targeting tree
Section titled “Targeting tree”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_applied → wc_coupon_applied, dino_campaign_applied → dino_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.
Virtual coupons
Section titled “Virtual coupons”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_couponposts), Dino campaign codes (merchant-entered, e.g. “BLUEMONDAY”), and Dino virtualdino_dd_*codes. UseCartCoupons::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_stackingflag no longer exists onRuleMatcher; the migration rewrites surviving rules to the tree form. The global setting was also renamedwc_coupon_stacking→wc_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.
4. Extension points
Section titled “4. Extension points”| Surface | Reference |
|---|---|
| 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”❌ Custom recipe
Section titled “❌ Custom recipe”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_strategiesfilter — 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.
✅ Add a new targeting field
Section titled “✅ Add a new targeting field”Worked example: cart_has_tag (cart contains a product with tag X).
- Evaluator — add a
case 'cart_has_tag':branch inTargetingEvaluator::evaluate_leaf(). Read$context['cart'], iterate line items, checkwc_get_product( $item['product_id'] )->get_tags()against$leaf['values']. - Sanitiser — register the field in
RuleSanitizerso saved rules with this field don’t get stripped. Model it on an existing taxonomy field (product_cat). - Admin UI — add an option to the leaf-field dropdown in
src/components/rules/targeting/(mirrorproduct_cat). - REST taxonomy search — if users need to pick tags by label, extend
TaxonomyControllerto surface tag results, or reuse the existing/taxonomy/searchendpoint. - Tests — unit test the evaluator branch in
tests/Unit/Engine/TargetingEvaluatorTest.php. Property-test the sanitiser round-trip. Integration test a full cart scenario intests/Integration/.
Keep the change surgical — do not add recipe-specific plumbing for this field. It’s a standard leaf.
✅ Filter display copy
Section titled “✅ Filter display copy”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:
| Hook | Fires | Payload | Use for |
|---|---|---|---|
dino_discounts_discount_applied | Per virtual coupon applied | $code, $data, $cart | Per-discount analytics (GA4 item-level, Segment track) |
dino_discounts_after_apply_coupons | Once per cart evaluation | $discounts[], $cart | Whole-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.
✅ Extend the Store API cart payload
Section titled “✅ Extend the Store API cart payload”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.
6. Pitfalls you’ll hit
Section titled “6. Pitfalls you’ll hit”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.
Zone context on multi-country stores
Section titled “Zone context on multi-country stores”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.
Recipes are not the engine
Section titled “Recipes are not the engine”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.
Proxy-code session hydration
Section titled “Proxy-code session hydration”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.
Virtual coupon prefix is reserved
Section titled “Virtual coupon prefix is reserved”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.
7. Testing conventions
Section titled “7. Testing conventions”| Command | What it runs | Container needed? |
|---|---|---|
make qa | Unit tests, lint, PHPStan, JS build, i18n drift, hook-doc drift | No — runs locally in ~45s |
vendor/bin/phpunit -c phpunit.unit.xml | PHP unit suite only | No |
npm run test:unit | JS unit suite | No |
make qa-deep | Unit + integration + E2E | Yes — shared Docker container, needs approval |
npm run test:integration | PHP integration (WP test suite) | Yes |
npm run test:e2e | Playwright E2E | Yes |
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.
8. Known constraints
Section titled “8. Known constraints”- 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-deepor E2E in parallel will collide. SeeCLAUDE.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 inRuleSanitizer). - Coupon generation cap — 10,000 codes per bulk-generate request.
- REST auth — every route is admin-only (
manage_woocommerce). No public routes.
9. Where to ask for help
Section titled “9. Where to ask for help”- 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.