DOCS

v1.3.28
 // Pro v1.2.20

 · Latest

Docs/Extending Order Daemon/Creating Custom Rule Components

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.

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 (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 manually register your class in code if it is placed in a loadable location and 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: OrderDaemonCompletionManagerCoreRuleComponentsInterfaces
  • 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

Tip: Return string IDs wrapped in translation functions using the text domain order-daemon. Use stable string keys following the pattern: __('rule_component.{type}.{id}.label', 'order-daemon').

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: InterfacesTriggerInterface
  • Contract: should_trigger(array $context, array $settings = []): bool

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

use OrderDaemonCompletionManagerCoreRuleComponentsInterfacesComponentInterface;
use OrderDaemonCompletionManagerCoreRuleComponentsInterfacesTriggerInterface;

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
    {
        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: InterfacesConditionInterface
  • Contract: evaluate(WC_Order $order, array $settings): bool
use OrderDaemonCompletionManagerCoreRuleComponentsInterfacesComponentInterface;
use OrderDaemonCompletionManagerCoreRuleComponentsInterfacesConditionInterface;
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)
use OrderDaemonCompletionManagerCoreRuleComponentsInterfacesActionInterface;
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.

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.

Universal Event 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

use OrderDaemonCompletionManagerCoreRuleComponentsInterfacesTriggerInterface;
use OrderDaemonCompletionManagerCoreEventsUniversalEvent;

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
    {
        if (isset($context['universal_event']) && $context['universal_event'] instanceof UniversalEvent) {
            $event = $context['universal_event'];
            return strpos($event->eventType, 'payment_completed') !== false ||
                   strpos($event->eventType, 'payment_succeeded') !== false;
        }
        return ($context['payment_status'] ?? '') === 'completed';
    }
}

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

use OrderDaemonCompletionManagerCoreEventsAdaptersAbstractGatewayAdapter;
use OrderDaemonCompletionManagerCoreEventsUniversalEvent;

class CustomGatewayAdapter extends AbstractGatewayAdapter
{
    public function __construct()
    {
        parent::__construct('custom_gateway');
    }

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

    public function canHandle(array $input_data): bool {
        return isset($input_data['payload']['gateway']) &&
               $input_data['payload']['gateway'] === 'custom_gateway';
    }

    public function validateAuthenticity(array $input_data): bool {
        $signature = $input_data['headers']['x-custom-signature'] ?? '';
        $payload   = $input_data['payload'];
        $expected  = hash_hmac('sha256', json_encode($payload), 'your_shared_secret');
        return hash_equals($expected, $signature);
    }

    public function normalize(array $input_data): array {
        $payload = $input_data['payload'];
        $event   = new UniversalEvent([
            'eventType'         => $this->mapEventType($payload['event_type']),
            'sourceGateway'     => 'custom_gateway',
            'channel'           => 'webhook',
            'primaryObjectType' => 'order',
            'primaryObjectID'   => $payload['order_id'] ?? null,
            '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'),
            'rawData'           => $payload,
        ]);
        return [$event];
    }

    public function computeIdempotencyKey(array $input_data): string {
        $payload = $input_data['payload'];
        return 'custom_gateway_' . ($payload['transaction_id'] ?? md5(json_encode($payload)));
    }

    protected function extractTransactionId(array $payload): ?string {
        return $payload['transaction_id'] ?? null;
    }

    protected function extractGatewaySpecificMetadata(array $input): array {
        $payload = $input['payload'];
        return [
            'gateway_event_type'     => $payload['event_type'] ?? null,
            'gateway_transaction_id' => $payload['transaction_id'] ?? null,
        ];
    }

    protected function mapEventType(string $gateway_event): string {
        return [
            'payment.success'      => 'payment_completed',
            'payment.failure'      => 'payment_failed',
            'subscription.created' => 'subscription_created',
        ][$gateway_event] ?? 'custom_event';
    }
}

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

Best practices for universal events integration:

  • Always generate stable idempotency keys to prevent duplicate processing.
  • Implement thorough payload validation in your adapters.
  • Provide meaningful error messages for debugging.
  • Keep adapter processing fast and efficient.
  • Use the plugin’s logging system (odcm_log_event()) for debugging information.
  • Implement proper signature verification for webhooks.

What’s next