Siren

Custom Incentive Types & Resolvers

Implementing Incentive and IncentiveResolver interfaces for custom reward calculations.

Last updated: April 6, 2026

Custom Incentive Types & Resolvers

Siren separates the concept of what triggers a reward (the Incentive) from how the reward is distributed among competing collaborators (the IncentiveResolver). This separation allows flexible commission structures. The same incentive type (e.g., percentage-of-sale) can use different attribution models (e.g., oldest engagement wins, evenly shared pool).

The Incentive Interface

The Incentive interface defines a reward structure. It specifies what conversions it applies to and how it creates conversions and obligations:

namespace Siren\Incentives\Core\Interfaces;

use Siren\Programs\Core\Models\Program;
use Siren\Conversions\Core\Models\Conversion;
use Siren\Obligations\Core\Models\Obligation;
use Siren\Transactions\Core\Models\Transaction;

interface Incentive
{
    /**
     * Whether this incentive should run for the given program.
     */
    public function shouldRun(Program $program): bool;

    /**
     * The conversion types this incentive supports (e.g., ['sale', 'renewal']).
     */
    public function getConversionTypes(): array;

    /**
     * Creates Conversion records from this incentive structure.
     *
     * @param int $opportunityId
     * @param int $programId
     * @param Transaction|null $transaction
     * @param string $conversionType
     * @return Conversion[]
     */
    public function maybeCreateConversions(
        int $opportunityId,
        int $programId,
        ?Transaction $transaction,
        string $conversionType
    ): array;

    /**
     * Re-creates conversions for renewals using existing engagements.
     */
    public function renewConversions(
        array $engagements,
        ?Transaction $transaction,
        string $conversionType
    ): array;

    /**
     * Creates an obligation from a conversion, using the resolver to calculate amount.
     */
    public function maybeCreateObligation(
        Conversion $conversion,
        ?Transaction $transaction,
        IncentiveResolver $incentiveResolver
    ): ?Obligation;

    /**
     * Creates a renewal obligation using provided engagements.
     */
    public function maybeRenewObligation(
        Conversion $conversion,
        ?Transaction $transaction,
        IncentiveResolver $incentiveResolver,
        array $engagements
    ): ?Obligation;

    public static function getId(): string;
    public static function getName(): string;
    public static function getDescription(): string;
}

Incentive types define the reward calculation (e.g., “10% of the transaction total” or “$5 per product”). They are responsible for creating both the conversion records and the resulting obligations.

What interface do incentive resolvers implement?

The IncentiveResolver determines how the calculated reward amount is distributed when multiple collaborators have engaged with the same opportunity. It answers: “Given a reward pool, which collaborator(s) get how much?”

namespace Siren\Incentives\Core\Interfaces;

use Siren\Commerce\Models\Amount;
use Siren\Conversions\Core\Models\Conversion;
use Siren\Transactions\Core\Models\Transaction;

interface IncentiveResolver
{
    /**
     * Resolves the actual reward amount for a specific conversion.
     *
     * @param Amount $rewardPool The total reward available.
     * @param Conversion $conversion The conversion being resolved.
     * @param Transaction|null $transaction The associated transaction.
     * @return Amount The resolved reward amount for this conversion's collaborator.
     */
    public function getRewardAmount(
        Amount $rewardPool,
        Conversion $conversion,
        ?Transaction $transaction
    ): Amount;

    public static function getId(): string;
    public static function getName(): string;
    public static function getDescription(): string;
}

The Amount Model

The Amount model represents a monetary value in the smallest currency unit (e.g., cents for USD):

namespace Siren\Commerce\Models;

class Amount
{
    public function __construct(int $value, Currency $currency) {}
    public function getValue(): int {}
    public function getCurrency(): Currency {}
}

When a resolver returns new Amount(0, $rewardPool->getCurrency()), that collaborator receives nothing. When it returns the full $rewardPool, that collaborator receives the entire reward.

Built-in Resolvers

Core Resolvers (Lite)

The core resolvers handle the most common attribution models. OldestBindingWins gives the full reward to whichever collaborator engaged first, while NewestBindingWins does the opposite. The most recent engagement takes all. Both support renewals via CanRenew.

Essentials Resolvers

The Essentials tier adds more nuanced options. EvenlySharedPool divides the reward equally among all collaborators with active engagements, using Num::getDividedInt() for integer division in the smallest currency unit. TopScoreWins gives the full reward to the collaborator with the highest engagement score, where scores accumulate based on program configuration (e.g., each site visit adds points). EveryBindingWins pays the full reward to every collaborator, which means the total payout can exceed the original reward pool. And PerformanceSharePool distributes proportionally based on relative engagement scores. Higher scores get a larger share.

Registration Pattern

Resolvers are registered by listening for the IncentiveResolverRegistryInitiated event:

namespace Siren\Incentives\Core\Handlers;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use Siren\Incentives\Core\Events\IncentiveResolverRegistryInitiated;

class RegisterCoreIncentiveResolvers implements CanHandle
{
    public function handle(Event $event): void
    {
        $event->addStrategy(NewestBindingWins::class);
        $event->addStrategy(OldestBindingWins::class);
    }
}

The addStrategy() method registers a lazy factory using the DI container:

public function addStrategy(string $strategyClass): void
{
    $this->registry->set(
        $strategyClass::getId(),
        fn() => $this->provider->get($strategyClass)
    );
}

Wire your listener in your Initializer:

public function getListeners(): array
{
    return [
        IncentiveResolverRegistryInitiated::class => RegisterMyResolvers::class,
    ];
}

Creating a Custom Resolver

Example: Tiered Commission Resolver

This resolver gives higher-performing collaborators a larger share of the reward pool, using configurable tiers based on total engagement count:

namespace MyPlugin\Incentives;

use Siren\Commerce\Models\Amount;
use Siren\Conversions\Core\Models\Conversion;
use Siren\Engagements\Core\Datastores\Engagement\Interfaces\EngagementDatastore;
use Siren\Engagements\Core\Models\Engagement;
use Siren\Incentives\Core\Interfaces\IncentiveResolver;
use Siren\Transactions\Core\Models\Transaction;

class TieredCommissionResolver implements IncentiveResolver
{
    public function __construct(
        protected EngagementDatastore $engagements
    ) {}

    public function getRewardAmount(
        Amount $rewardPool,
        Conversion $conversion,
        ?Transaction $transaction
    ): Amount {
        /** @var Engagement $engagement */
        $engagement = $this->engagements->find($conversion->getEngagementId());

        // Count total historical engagements for this collaborator
        $totalEngagements = $this->engagements->getActiveEngagementCount(
            $engagement->getOpportunityId(),
            $engagement->getProgramId()
        );

        // Apply tier multiplier based on engagement volume
        $multiplier = match (true) {
            $totalEngagements >= 100 => 1.5,  // 150% for high performers
            $totalEngagements >= 50  => 1.25, // 125% for mid-tier
            $totalEngagements >= 10  => 1.0,  // 100% baseline
            default                  => 0.75, // 75% for newcomers
        };

        $amount = (int) round($rewardPool->getValue() * $multiplier);

        return new Amount($amount, $rewardPool->getCurrency());
    }

    public static function getId(): string
    {
        return 'tieredCommission';
    }

    public static function getName(): string
    {
        return 'Tiered Commission';
    }

    public static function getDescription(): string
    {
        return 'Adjusts commission based on collaborator engagement volume.';
    }
}

Register It

namespace MyPlugin\Listeners;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use Siren\Incentives\Core\Events\IncentiveResolverRegistryInitiated;
use MyPlugin\Incentives\TieredCommissionResolver;

class RegisterTieredResolver implements CanHandle
{
    public function handle(Event $event): void
    {
        if ($event instanceof IncentiveResolverRegistryInitiated) {
            $event->addStrategy(TieredCommissionResolver::class);
        }
    }
}

Supporting Renewals

If your resolver needs to handle subscription renewals, implement the CanRenew interface:

use Siren\Incentives\Core\Interfaces\CanRenew;

class MyResolver implements IncentiveResolver, CanRenew
{
    public function getRewardAmount(Amount $rewardPool, Conversion $conversion, ?Transaction $transaction): Amount
    {
        // Initial conversion logic
    }

    public function getRenewalRewardAmount(
        Amount $rewardPool,
        Conversion $conversion,
        ?Transaction $transaction,
        array $engagements
    ): Amount {
        // Renewal logic -- engagements are passed directly
        // since the original opportunity may no longer be active
    }
}

The renewal method receives the engagements array directly rather than looking them up by opportunity, because during renewals the original opportunity context may have changed.

Removing or Replacing a Built-in Resolver

Use deleteStrategy() on the registry event:

public function handle(Event $event): void
{
    if ($event instanceof IncentiveResolverRegistryInitiated) {
        $event->deleteStrategy('oldestBindingWins');
        $event->addStrategy(MyCustomResolver::class);
    }
}

How It All Connects

  1. An Incentive (e.g., SaleTransactionPercentage) calculates the reward pool from the transaction amount
  2. The Incentive calls maybeCreateObligation(), passing the reward pool to the IncentiveResolver
  3. The IncentiveResolver (e.g., OldestBindingWins) determines how much of that pool this specific collaborator receives
  4. The resulting Amount becomes the obligation value. This is what the business owes that collaborator.