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_TYPESsibling map), the per-section sanitiser, and theRuleMatchersection evaluators. The older recursive targeting tree (targeting_tree,TargetingEvaluator, top-leveltype/priority, thedino_coupon_appliedtargeting 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.
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 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.
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 scripts/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: { operator: "gte", value: 1 }(the value is read in the cart’s 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/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).
3. Core concepts
Section titled “3. Core concepts”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.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” |
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-chainsWhoEvaluator::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[]), andRuleMatcher::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.
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 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_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 (the sanitiser sets this default inRuleSanitizer::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.
4. Extension points
Section titled “4. Extension points”| Surface | Reference |
|---|---|
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-inrewards.kind(rare —UnifiedStrategyserves all three). It cannot add a kind: the rule schema rejects any otherrewards.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”❌ 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 replacement strategy via the
dino_discounts_strategiesfilter — if your discount math for a built-intiered/bulk/pooled_matchkind 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.
✅ 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; 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:
| 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 storefront JS payload
Section titled “✅ Extend the storefront JS payload”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.
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 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.
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 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.
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 Build any discount or the REST API. Engine logic goes in the engine.
Proxy-code session hydration
Section titled “Proxy-code session hydration”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.
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 (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.
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, repo guard scripts | No |
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/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).
8. Known constraints
Section titled “8. Known constraints”- 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
:80container serves one session at a time; runningmake qa-deepor E2E against it in parallel will collide. Isolated lanes (LANE=<port>) run in parallel — seedocs/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/seedandPOST /telemetry-consentneedmanage_options,POST /bug-report/submitneeds a logged-in user withedit_posts(filterable), andPOST /compat/{id}/probeis open to storefront visitors (it stores one enumerated probe outcome, validated in the callback).
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). - 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.