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_TYPESsibling map) has merged. The retired surface (targeting_tree,RuleFields::allowed_input_fields(),TargetingEvaluator’s recursive switch, top-leveltype/priority, thedino_coupon_appliedtargeting leaf, and the v4.20.0 enginecases 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.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 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.
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
trigger.spend_threshold: { currency: "GBP", operator: "gte", value: 100 }(or your store currency). - 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).
3. Core concepts
Section titled “3. Core concepts”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.kind | Purpose |
|---|---|
tiered | Spend / qty thresholds (£50 = 5% off, £100 = 10% off) |
bulk | Per-line volume pricing |
pooled_match | Pool-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-chainsWhoEvaluator::evaluate()→WhereEvaluator::evaluate()→WhenEvaluator::evaluate()→TriggerEvaluator::evaluate(), short-circuiting on first failure.RuleMatcher::evaluate_item(rule, item_ctx)— calls the per-itemevaluate_eligible_itemstwice: once withrule.trigger.eligible_items(admission), once with the resolved reward pool (rule.rewards.eligible_items, falling back to the trigger pool whenmode === "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.
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 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_couponposts), Dino coupon 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
trigger.wc_coupon_stacking: "block" | "allow". Default is"block"for automatic rules;"allow"for coupon-tied rules whentrigger.coupon_idsis 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.
4. Extension points
Section titled “4. Extension points”| Surface | Reference |
|---|---|
| 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 additionalrewards.kindto a custom strategy class (rare — the three built-in kinds, all served byUnifiedStrategy, 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”❌ 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 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_strategiesfilter — if your discount math is genuinely different from the built-intiered/bulk/pooled_matchkinds.
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.
✅ Add a new field to a section
Section titled “✅ Add a new field to a section”Worked example: a who.has_active_subscription: boolean | null field that fires only for customers with a current WC Subscriptions active subscription.
- Schema fragment — add
has_active_subscriptiontoincludes/Engine/Schema/WhoSchema.php, with a Draft-07 type clause andnullpermitted. - Section evaluator — extend the
whoevaluator to read the new field. Short-circuitnull ⇒ skipand otherwise compare against$cart_ctx->customer_has_active_subscription. - Sanitiser — add the per-field sanitiser in
RuleSanitizer’swhosection pass; reject anything that isn’ttrue,false, ornull. - FieldId enum — add
HAS_ACTIVE_SUBSCRIPTION: "who.has_active_subscription"tosrc/contracts/recipeFieldEnums.jsand a matchingFIELD_TYPESentry (typed metadata for the property-based test generator). - Admin UI — add the control to the Who section in the wizard.
- 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.
✅ 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 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.
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 — see .agents/PRINCIPLES.md.
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). 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.
9. Where to ask for help
Section titled “9. Where to ask for help”- Active work: task files live one-per-file under
.agents/tasks/. Aggregate state is derived on demand byscripts/tasks.sh— there is no central registry (TASK_REGISTRY.mdwas 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.