Custom Rules: Build Triggers, Conditions, & Actions

Order Daemon Docs

Creating Custom Rule Components

This guide shows how to extend Order Daemon by adding your own rule components: Triggers, Conditions, and Actions. It focuses on stable contracts and best practices, and avoids internal-only details.

What you’ll learn

  • Where component classes live and how they are discovered
  • Which PHP interfaces to implement and what each method should return
  • How entitlement (free vs Pro) interacts with your components
  • Performance, safety, and i18n tips
  • How to integrate with the Universal Events system
  • Rule validation and error handling patterns

Prerequisites

  • WordPress + WooCommerce basics
  • PHP 7.4+ (PHP 8.x recommended)
  • Familiarity with WooCommerce orders (WC_Order)

How components are discovered

Order Daemon discovers components via a registry:

  • Class: src/Core/RuleComponents/RuleComponentRegistry.php
  • It scans the component namespaces/paths for Triggers, Conditions, and Actions.
  • Any concrete class that implements the correct interface is available to the Rule Builder, the REST API, and the engine.

You do not need to “register” your class manually in code if you place it in a loadable location and it is autoloaded. If you ship an add-on, make sure your plugin’s composer autoload or classloader is set up so your classes are available.

Shared interface for all components

Implement ComponentInterface for metadata used by the UI and REST:

  • Namespace: OrderDaemon\CompletionManager\Core\RuleComponents\Interfaces
  • Methods you must implement:
  • get_id(): string — unique, lowercase, snake_case id (stable; used in DB and APIs)
  • get_label(): string — human readable label (translatable)
  • get_description(): string — longer help text (translatable)
  • get_settings_schema(): ?array — JSON-like schema (PHP array) for your settings form; return [] or null if no settings

i18n tip: Return string IDs wrapped in translation functions using the text domain order-daemon.
Example: __( 'components.my_condition.label', 'order-daemon' ).

Note: Additional methods like get_priority() and is_default() are commonly implemented but not required by the interface.

Triggers

Implement TriggerInterface in addition to ComponentInterface:

  • Namespace: Interfaces\TriggerInterface
  • Contract: should_trigger(array $context, array $settings = []): bool

Notes

  • Simple triggers can return true and let the engine decide based on configured metadata.
  • Complex triggers can inspect the $context or $settings to decide whether to wake the rule.

Skeleton

use OrderDaemon\CompletionManager\Core\RuleComponents\Interfaces\ComponentInterface;
use OrderDaemon\CompletionManager\Core\RuleComponents\Interfaces\TriggerInterface;

final class OrderStatusProcessingTrigger implements TriggerInterface
{
    public function get_id(): string { return 'order_status_to_processing'; }
    public function get_label(): string { return __('components.trigger.processing.label', 'order-daemon'); }
    public function get_description(): string { return __('components.trigger.processing.desc', 'order-daemon'); }
    public function get_settings_schema(): ?array { return []; }

    public function should_trigger(array $context, array $settings = []): bool
    {
        // Example: expect from->to in $context
        return ($context['to_status'] ?? '') === 'processing';
    }
}

Best practices

  • Keep should_trigger() fast; avoid heavy queries here.
  • Use clear, stable IDs. They are persisted with rules.

Conditions

Implement ConditionInterface in addition to ComponentInterface:

  • Namespace: Interfaces\ConditionInterface
  • Contract: evaluate(WC_Order $order, array $settings): bool

Skeleton

use OrderDaemon\CompletionManager\Core\RuleComponents\Interfaces\ComponentInterface;
use OrderDaemon\CompletionManager\Core\RuleComponents\Interfaces\ConditionInterface;
use WC_Order;

final class OrderTotalAtLeastCondition implements ConditionInterface
{
    public function get_id(): string { return 'order_total_at_least'; }
    public function get_label(): string { return __('components.condition.total_at_least.label', 'order-daemon'); }
    public function get_description(): string { return __('components.condition.total_at_least.desc', 'order-daemon'); }
    public function get_settings_schema(): ?array {
        return [
            'type' => 'object',
            'properties' => [
                'amount' => [ 'type' => 'number', 'minimum' => 0, 'title' => __('components.fields.amount', 'order-daemon') ],
            ],
            'required' => ['amount'],
        ];
    }

    public function evaluate(WC_Order $order, array $settings): bool
    {
        $amount = (float) ($settings['amount'] ?? 0);
        return (float) $order->get_total() >= $amount;
    }
}

Best practices

  • Minimize DB calls; re-use data available on WC_Order.
  • Validate and sanitize settings defensively; the REST layer also validates against your schema.
  • Log sparingly; prefer the engine’s ProcessLogger for detailed traces.

Actions

Actions implement ComponentInterface. The concrete execute signature is provided by the engine when invoking actions, passing the current evaluation context and your action’s settings. Keep the execute method idempotent where possible (running twice should not cause harm).

Common patterns for actions include:

  • Changing order status using WooCommerce APIs
  • Adding an order note
  • Tagging/flagging orders (via meta)

Skeleton (illustrative)

use OrderDaemon\CompletionManager\Core\RuleComponents\Interfaces\ActionInterface;
use WC_Order;

final class MarkCompletedAction implements ActionInterface
{
    public function get_id(): string { return 'set_status_completed'; }
    public function get_label(): string { return __('components.action.complete.label', 'order-daemon'); }
    public function get_description(): string { return __('components.action.complete.desc', 'order-daemon'); }
    public function get_settings_schema(): ?array { return []; }

    public function execute(WC_Order $order, array $settings): void
    {
        if ($order->get_status() !== 'completed') {
            $order->update_status('completed', __('components.action.complete.note', 'order-daemon'));
        }
    }
}

Best practices

  • Use WooCommerce APIs (update_status, add_order_note) rather than direct DB writes.
  • Be idempotent: if the order is already in the desired state, return a neutral/success outcome without duplicating work.
  • Include short, translatable notes/messages where user-facing.

Settings schema tips (for the Rule Builder UI)

  • Return a compact JSON-like schema that the Rule Builder can render.
  • Supported concepts include: type, properties, title/description, enum/options, defaults. You can add UI hints such as ui:widget or ui:placeholder if supported by the current UI.
  • Keep labels/descriptions translatable via order-daemon.

Example snippet

return [
  'type' => 'object',
  'properties' => [
    'categories' => [
      'type' => 'array',
      'items' => [ 'type' => 'integer' ],
      'title' => __('components.fields.categories', 'order-daemon'),
      'ui:widget' => 'category-picker',
    ],
  ],
];

Performance and safety

  • Avoid heavy queries in should_trigger/evaluate/execute. If you need lookups, cache results for the duration of the request.
  • Validate settings server-side even if the UI enforces them. The REST layer validates against your schema, but your code should still defend against malformed data.
  • Use i18n for any user-facing strings with the order-daemon text domain.
  • For admin/AJAX tools in your own add-on, prefer the Guard pattern (capability + nonce) and standard WP permission checks.

Testing and troubleshooting

  • Verify your class is autoloaded and implements the correct interface; the registry ignores abstract classes and non-matching types.
  • Use the Insight (Audit Log) dashboard to confirm when your component runs and what result it returns.
  • Check that your component appears in the Rule Builder UI and can be selected in rules.

Universal Events System Integration

Order Daemon’s Universal Events system provides a unified way to handle events from various sources (payment gateways, webhooks, manual triggers) and route them to rule processing. Understanding this system is crucial for creating advanced custom rule components.

Universal Event Architecture

The Universal Events system consists of several key components:

  1. UniversalEvent Object: Normalizes all lifecycle events into a consistent structure
  2. EventRouter: Routes incoming events to appropriate gateway adapters
  3. GatewayAdapters: Convert gateway-specific payloads to UniversalEvent format
  4. UniversalEventProcessor: Processes events and triggers rule evaluation

Universal Event Structure

The UniversalEvent class (src/Core/Events/UniversalEvent.php) provides a comprehensive structure:

// Key properties of UniversalEvent
$event->eventType;          // Normalized event type (e.g., 'payment_completed')
$event->sourceGateway;      // Gateway name (e.g., 'stripe', 'paypal')
$event->channel;            // Event channel (webhook, ipn, sdk, manual, system, scheduled)
$event->primaryObjectType;  // Primary entity type (order, subscription, refund, etc.)
$event->primaryObjectID;    // Primary entity ID
$event->transactionID;      // Gateway transaction ID
$event->status;             // Gateway status (COMPLETED, DENIED, etc.)
$event->amount;             // Transaction amount
$event->currency;           // Currency code
$event->occurredAt;         // When event occurred (ISO8601)
$event->receivedAt;         // When plugin received event (ISO8601)
$event->idempotencyKey;     // Stable key for deduplication
$event->rawData;            // Original payload (sanitized)
$event->components;         // UI components for timeline rendering

Creating Universal Event-Aware Triggers

To create triggers that respond to Universal Events, implement the TriggerInterface and check for specific event types:

use OrderDaemon\CompletionManager\Core\RuleComponents\Interfaces\TriggerInterface;
use OrderDaemon\CompletionManager\Core\Events\UniversalEvent;

final class PaymentCompletedTrigger implements TriggerInterface
{
    public function get_id(): string { return 'payment_completed'; }
    public function get_label(): string { return __('Payment Completed', 'order-daemon'); }
    public function get_description(): string { return __('Trigger when payment is completed via any gateway', 'order-daemon'); }
    public function get_settings_schema(): ?array { return []; }

    public function should_trigger(array $context, array $settings = []): bool
    {
        // Check if this is a Universal Event context
        if (isset($context['universal_event']) && $context['universal_event'] instanceof UniversalEvent) {
            $event = $context['universal_event'];

            // Trigger on payment completion events from any gateway
            return strpos($event->eventType, 'payment_completed') !== false ||
                   strpos($event->eventType, 'payment_succeeded') !== false;
        }

        // Fallback for legacy contexts
        return ($context['payment_status'] ?? '') === 'completed';
    }
}

Event Type Taxonomy

Order Daemon uses a standardized event type taxonomy:

Payment Events:

  • payment_created, payment_completed, payment_denied, payment_refunded, payment_reversed

Subscription Events:

  • subscription_created, subscription_approved, subscription_reactivated, subscription_suspended, subscription_cancelled, subscription_completed

Renewal Events:

  • renewal_payment_processing, renewal_payment_pending, renewal_payment_failed, renewal_payment_completed

Dispute Events:

  • dispute_opened, dispute_won, dispute_lost

Creating Custom Gateway Adapters

For advanced integrations, you can create custom gateway adapters:

use OrderDaemon\CompletionManager\Core\Events\GatewayEventAdapter;
use OrderDaemon\CompletionManager\Core\Events\UniversalEvent;

class CustomGatewayAdapter extends GatewayEventAdapter
{
    public function getGatewayName(): string
    {
        return 'custom_gateway';
    }

    public function getSupportedEventTypes(): array
    {
        return [
            'payment_completed',
            'payment_failed',
            'subscription_created',
        ];
    }

    public function canHandle(array $input_data): bool
    {
        // Check if this payload is from your custom gateway
        return isset($input_data['payload']['gateway']) &&
               $input_data['payload']['gateway'] === 'custom_gateway';
    }

    public function validateAuthenticity(array $input_data): bool
    {
        // Implement signature verification for your gateway
        $signature = $input_data['headers']['x-custom-signature'] ?? '';
        $payload = $input_data['payload'];

        return $this->verifyCustomSignature($payload, $signature);
    }

    public function normalize(array $input_data): array
    {
        $payload = $input_data['payload'];
        $event_type = $this->mapEventType($payload['event_type']);
        $order_id = $this->extractOrderId($payload);

        $event = new UniversalEvent([
            'eventType' => $event_type,
            'sourceGateway' => 'custom_gateway',
            'channel' => 'webhook',
            'primaryObjectType' => 'order',
            'primaryObjectID' => $order_id,
            'transactionID' => $payload['transaction_id'] ?? null,
            'status' => $payload['status'] ?? 'COMPLETED',
            'amount' => $payload['amount'] ?? null,
            'currency' => $payload['currency'] ?? 'USD',
            'occurredAt' => $payload['created_at'] ?? current_time('c'),
            'receivedAt' => current_time('c'),
            'idempotencyKey' => $this->generateIdempotencyKey($payload),
            'rawData' => $payload,
            'components' => $this->createComponents($payload),
        ]);

        return [$event];
    }

    public function identifyEntities(UniversalEvent $event): UniversalEvent
    {
        // Resolve order, customer, and other entities
        if ($event->primaryObjectType === 'order' && $event->primaryObjectID) {
            $order = wc_get_order($event->primaryObjectID);
            if ($order) {
                $event->components['order'] = [
                    'id' => $order->get_id(),
                    'status' => $order->get_status(),
                    'total' => $order->get_total(),
                    'currency' => $order->get_currency(),
                ];
            }
        }

        return $event;
    }

    private function verifyCustomSignature(array $payload, string $signature): bool
    {
        // Implement your gateway's signature verification
        $expected = hash_hmac('sha256', json_encode($payload), 'your_shared_secret');
        return hash_equals($expected, $signature);
    }

    private function mapEventType(string $gateway_event): string
    {
        $mapping = [
            'payment.success' => 'payment_completed',
            'payment.failure' => 'payment_failed',
            'subscription.created' => 'subscription_created',
        ];

        return $mapping[$gateway_event] ?? 'custom_event';
    }

    private function extractOrderId(array $payload): ?int
    {
        // Extract order ID from gateway payload
        if (isset($payload['order_id'])) {
            return (int) $payload['order_id'];
        }

        if (isset($payload['metadata']['order_id'])) {
            return (int) $payload['metadata']['order_id'];
        }

        return null;
    }

    private function generateIdempotencyKey(array $payload): string
    {
        $components = [
            'custom_gateway',
            $payload['event_type'] ?? 'unknown',
            $payload['transaction_id'] ?? uniqid(),
            $payload['created_at'] ?? current_time('c'),
        ];

        return 'odcm_custom_' . substr(md5(implode('|', $components)), 0, 16);
    }

    private function createComponents(array $payload): array
    {
        $components = [];

        // Add gateway-specific components
        $components[] = [
            'k' => 'gateway_icon',
            'type' => 'icon',
            'icon' => '💳',
            'label' => 'Custom Gateway',
        ];

        // Add payment information
        if (isset($payload['amount'], $payload['currency'])) {
            $components[] = [
                'k' => 'payment_amount',
                'type' => 'text',
                'label' => 'Amount',
                'value' => $payload['currency'] . ' ' . number_format($payload['amount'], 2),
            ];
        }

        return $components;
    }
}

Registering Custom Adapters

Register your custom adapter with the EventRouter:

add_action('odcm_register_gateway_adapters', function($router) {
    $router->registerAdapter(new CustomGatewayAdapter());
});

Event Processing Flow

  1. Webhook Received: External service sends webhook to Order Daemon endpoint
  2. Adapter Selection: EventRouter selects appropriate gateway adapter
  3. Authentication: Adapter validates request authenticity (signatures, etc.)
  4. Normalization: Adapter converts payload to UniversalEvent format
  5. Entity Resolution: Adapter enriches event with resolved entities
  6. Event Dispatch: Events sent to UniversalEventProcessor for async processing
  7. Rule Evaluation: Processor triggers rule evaluation based on event type
  8. Action Execution: Matching rules execute their actions
  9. Audit Logging: All processing steps logged for debugging

Best Practices for Universal Events Integration

  1. Idempotency: Always generate stable idempotency keys to prevent duplicate processing
  2. Validation: Implement thorough payload validation in your adapters
  3. Error Handling: Provide meaningful error messages for debugging
  4. Performance: Keep adapter processing fast and efficient
  5. Logging: Use the plugin’s logging system for debugging information
  6. Security: Implement proper signature verification for webhooks

Rule Validation and Error Handling

Order Daemon provides comprehensive validation and error handling:

// Example of rule validation in your component
public function validate_settings(array $settings): array
{
    $errors = [];

    // Validate required fields
    if (empty($settings['amount'])) {
        $errors[] = [
            'field' => 'amount',
            'message' => __('Amount is required', 'order-daemon'),
            'code' => 'missing_amount',
        ];
    }

    // Validate numeric values
    if (isset($settings['amount']) && !is_numeric($settings['amount'])) {
        $errors[] = [
            'field' => 'amount',
            'message' => __('Amount must be a valid number', 'order-daemon'),
            'code' => 'invalid_amount',
        ];
    }

    // Validate ranges
    if (isset($settings['amount']) && (float) $settings['amount'] < 0) {
        $errors[] = [
            'field' => 'amount',
            'message' => __('Amount must be positive', 'order-daemon'),
            'code' => 'negative_amount',
        ];
    }

    return $errors;
}

// Example of error handling in execute method
public function execute(WC_Order $order, array $settings, array $context = []): array
{
    try {
        // Validate order state
        if (!$order) {
            return [
                'status' => 'error',
                'message' => __('Invalid order', 'order-daemon'),
                'code' => 'invalid_order',
            ];
        }

        // Execute action
        $result = $this->perform_action($order, $settings);

        return [
            'status' => 'success',
            'message' => __('Action completed successfully', 'order-daemon'),
            'data' => $result,
        ];

    } catch (\Exception $e) {
        return [
            'status' => 'error',
            'message' => $e->getMessage(),
            'code' => 'execution_error',
            'details' => [
                'file' => $e->getFile(),
                'line' => $e->getLine(),
            ],
        ];
    }
}

Debugging Universal Events

Use these techniques to debug Universal Events:

  1. Insight Dashboard: Filter for event_routing and webhook_processing events
  2. Webhook Logs: Check /wp-json/odcm/v1/webhooks/logs/{gateway} endpoint
  3. Test Endpoints: Use /wp-json/odcm/v1/webhooks/test/{gateway} to test your adapters
  4. Debug Mode: Enable ODCM_DEBUG constant for detailed logging
  5. Event Validation: Use UniversalEvent::isValid() to check event structure

Was this article helpful?

  • Loading...
Table of Contents
  • Loading...