Coupon ↔ Rule Linking
{/* AUTO-GENERATED from ../docs/coupon-rule-linking.md by scripts/sync-dev-docs.mjs — do not edit by hand. */}
How coupons and discount rules are connected under the section-shaped rule model.
A rule names the coupons that unlock it in a flat trigger.coupon_ids: string[] | null field (includes/Engine/Schema/TriggerSchema.php). Coupon-side data (codes, activation, usage limits, typed-code resolution) lives in the Coupon entity (includes/Cart/CouponEntity.php, stored by CouponRepository).
Core principle
Section titled “Core principle”The rule’s trigger.coupon_ids field is the single source of truth for which coupons gate it. A rule fires when any one of the listed coupons is applied to the cart (OR-of-values within the field; null or [] ≡ no coupon requirement). Coupon rows carry no reference to rules — multiple rules may reference the same coupon id.
The Coupon entity owns: the coupon id, activation kind (single_code / url_token / bulk), usage limit, status (active / expired, shown as archived in the admin), and the typed-code-to-coupon-id resolver. The rule owns: which coupon ids unlock it.
Concepts
Section titled “Concepts”| Term | Description |
|---|---|
| Rule | A section-shaped discount configuration stored in the dino_discounts_active_rules option. A rule may set trigger.coupon_ids: ["BF20", "BLACKFRIDAY"] to require one of those coupons to be applied. |
| Coupon | A row in the wp_dino_coupons table, keyed by coupon_id (e.g. "BF20", stored uppercase). Bulk coupons also own a pool of generated codes in wp_dino_bulk_codes. Codes a customer types resolve to the parent coupon id via CouponResolver. |
| Link | Derived at runtime: a coupon is “linked” to a rule when the rule’s trigger.coupon_ids array contains that coupon’s id. No table stores a rule id against a coupon. |
| Draft link | A coupon referenced only by an unpublished editor change. Shown in the Coupons table as a draft pill with the tooltip “Linked to an unsaved discount. Publish to finalise.” |
Multiple rules can reference the same coupon
Section titled “Multiple rules can reference the same coupon”There is no one-to-one constraint. Multiple rules may list the same coupon id in their trigger.coupon_ids. Conversely a rule may require one of several coupons (OR-of-values).
Two coupons on the same rule = OR
Section titled “Two coupons on the same rule = OR”trigger.coupon_ids: ["A", "B"] fires when either A or B is applied. Cross-coupon AND (“must apply A and B”) is not expressible.
Lifecycle: what happens when
Section titled “Lifecycle: what happens when”1. Create coupon (inline, inside the rule editor’s Trigger section)
Section titled “1. Create coupon (inline, inside the rule editor’s Trigger section)”In What triggers it?, the admin ticks Type a coupon code at checkout, opens the coupon picker and chooses + Create new coupon. That opens the same New Coupon modal the Coupons tab uses, where Generate Random Code fills in a code and Create Coupon saves it.
Frontend Backend-------- -------GET /coupons/generate-random CouponCodesController::generate_random_coupon_code() (optional)
POST /coupons CouponCodesController::create_campaign() { coupon_id, activation: {kind}, -> CouponRepository::save() (inserts the coupon) ... } -> bulk only: CouponManager::generate_codes_with_report() <- returns { success, coupon, coupon_entity, ... }
React adds the coupon id to the (editor state + debounced draft autosave --rule's trigger.coupon_ids list not live until Publish)Result: The coupon exists in the DB. The rule references its id in the editor.
2. Select existing coupon (dropdown)
Section titled “2. Select existing coupon (dropdown)”The admin picks an existing coupon from the same picker.
Frontend Backend-------- -------No create call. Nothing changes in the coupon tables.
React adds the coupon id to (editor state + draft autosave)trigger.coupon_idsResult: The rule references the coupon in the editor. Nothing is live until Publish.
3. Draft autosave
Section titled “3. Draft autosave”Unpublished edits are autosaved (debounced, ~2 s) with PUT /dino-discounts/v1/rules/draft into the dino_discounts_draft option. Drafts are never read by the engine.
4. Publish discounts
Section titled “4. Publish discounts”Frontend Backend-------- -------POST /dino-discounts/v1/rules RulesController::save_rules() { rules: [...] } -> enforces the 200-rule cap -> sanitises each rule (RuleSanitizer) and schema-checks it; incomplete rules are skipped and reported, not published -> writes dino_discounts_active_rules -> archives coupons that lost their last referencing rule -> do_action('dino_discounts_rules_saved', $summaries) <- returns { rules, skipped, archived_coupons, ... }Result: The saved rules carry trigger.coupon_ids. The engine reads it at cart time. No separate linking step is needed.
A coupon that was referenced by the previously published rules and is referenced by none of the new ones is archived (status expired) by CouponManager::archive_coupons_no_longer_referenced(). It moves to the Coupons tab’s Archived filter, where it can be restored with its codes and redemption history.
5. Revert (discard unpublished changes)
Section titled “5. Revert (discard unpublished changes)”Resets the editor to the last published snapshot and deletes the server draft (DELETE /rules/draft). A coupon created in step 1 still exists: if no published rule references it, the Coupons table shows it as “No discount linked”. It can be linked to another rule or archived.
How the Coupons table derives links
Section titled “How the Coupons table derives links”The Coupons table works out which rule(s) reference each coupon client-side, by reading trigger.coupon_ids from two sources (findLinkedRules() in src/components/Coupons/couponRuleLinking.js):
- Editor rules (
window.__ddLocalRules) — the current unpublished editor state. A link found only here is flagged as a draft. - Published rules — from the rules API response.
Each source is a flat membership check on the uppercased trigger.coupon_ids values. The server draft option is not used for this.
Engine evaluation
Section titled “Engine evaluation”When a shopper applies a coupon code:
- WooCommerce’s
woocommerce_get_shop_coupon_datafilter reachesCouponCodeInterceptor::intercept_coupon_code()— at priority 9 by default, or 100 when the coupon-precedence setting iswc_first(defaultdino_first). - The interceptor resolves the typed code (via
TypedCodeResolver→CouponResolver::resolve()) against the Coupon entity: asingle_codecoupon id, aurl_tokenactivation token, or a generated code in a bulk pool. It returns the parent coupon id(s). RulesEngineseeds the cart context’sapplied_coupon_idswith those ids, andTriggerEvaluator::evaluate_coupon_ids()tests set membership:array_intersect($applied, $rule['trigger']['coupon_ids'])must be non-empty.
Database schema
Section titled “Database schema”wp_dino_coupons table
Section titled “wp_dino_coupons table”| Column | Type | Purpose |
|---|---|---|
coupon_id | VARCHAR(100) | Primary key — the stable id rules reference in trigger.coupon_ids. For single_code coupons it is also the code customers type. |
name | VARCHAR(255) | Optional display name |
status | ENUM | active or expired (archived). The enum also lists a retired paused value that nothing writes. |
activation_kind | ENUM('single_code','url_token','bulk') | How the coupon is redeemed |
activation_token | VARCHAR(100) | URL token for url_token coupons (unique) |
activation_bulk_id | VARCHAR(36) | Pool id for bulk coupons (unique) |
url_apply_notice | VARCHAR(255) | Optional notice shown when applied by URL |
usage_limit | INT | Max redemptions (NULL = unlimited) |
used_count | INT | Current redemption count |
created_at | DATETIME | Creation time |
wp_dino_bulk_codes table
Section titled “wp_dino_bulk_codes table”One row per generated code in a bulk pool: id, uuid, coupon_id (parent coupon), generation_log_id, code (unique), usage_limit, used_count, used_by, used_at, expires_at, created_at. There is no database foreign key — CouponRepository::delete() removes a coupon’s codes before the coupon.
Both tables are created in Migrations::create_custom_tables() (includes/Migrations.php).
Implementation: key files
Section titled “Implementation: key files”Backend (PHP)
Section titled “Backend (PHP)”| File | Key methods |
|---|---|
includes/API/CouponCodesController.php | REST endpoints under /coupons (GET list, POST create, per-coupon archive / restore / delete / generate / export, generate-random, wc-clash) |
includes/Cart/CouponRepository.php | find_by_id(), save(), delete(), get_bulk_codes() |
includes/Cart/CouponManager.php | generate_codes_with_report(), list_coupons(), archive_coupon(), restore_coupon(), archive_coupons_no_longer_referenced(), find_rules_for_coupon() |
includes/Cart/CouponResolver.php | resolve() — typed code / URL query → coupon id(s) |
includes/API/RulesController.php | save_rules() — sanitises and schema-checks rules, persists them, archives coupons that lost their last referrer |
includes/Engine/Schema/TriggerSchema.php | Schema fragment that declares trigger.coupon_ids: string[] | null |
includes/Engine/Evaluators/TriggerEvaluator.php | evaluate_coupon_ids() — tests trigger.coupon_ids against the applied coupon ids |
Frontend (React)
Section titled “Frontend (React)”| File | Role |
|---|---|
src/components/RuleBuilder/Trigger/TriggerSection.js | ”Type a coupon code at checkout” picker writing to trigger.coupon_ids, with + Create new coupon |
src/components/Coupons/NewCouponModal.js | Create-coupon modal used by both the Trigger section and the Coupons tab |
src/utils/ruleCouponHelpers.js | getCouponsFromRule(), addCouponToRule(), removeCouponFromRule() |
src/components/Coupons/couponRuleLinking.js | findLinkedRules() — derives linked rules for the Coupons table from editor + published rules |