Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/DependencyInjection/SetonoSyliusLoyaltyExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@

use Setono\SyliusLoyaltyPlugin\Earning\Trigger\AwardOrderPointsStateMachineListener;
use Setono\SyliusLoyaltyPlugin\Earning\Trigger\ClawbackOrderPointsStateMachineListener;
use Setono\SyliusLoyaltyPlugin\Earning\Trigger\ReviewApprovedStateMachineListener;
use Setono\SyliusLoyaltyPlugin\Redemption\RedemptionStateMachineListener;
use Setono\SyliusLoyaltyPlugin\Rule\Amount\EarningAmountInterface;
use Setono\SyliusLoyaltyPlugin\Rule\Condition\EarningConditionInterface;
use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension;
use Sylius\Bundle\ResourceBundle\SyliusResourceBundle;
use Sylius\Component\Core\OrderCheckoutTransitions;
use Sylius\Component\Core\OrderPaymentTransitions;
use Sylius\Component\Core\ProductReviewTransitions;
use Sylius\Component\Order\OrderTransitions;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
Expand Down Expand Up @@ -108,6 +110,7 @@ private function registerWinzouCallbacks(ContainerBuilder $container): void
$award = '@' . AwardOrderPointsStateMachineListener::class;
$clawback = '@' . ClawbackOrderPointsStateMachineListener::class;
$redemption = '@' . RedemptionStateMachineListener::class;
$reviewAward = '@' . ReviewApprovedStateMachineListener::class;

$container->prependExtensionConfig('winzou_state_machine', [
OrderPaymentTransitions::GRAPH => [
Expand Down Expand Up @@ -158,6 +161,17 @@ private function registerWinzouCallbacks(ContainerBuilder $container): void
],
],
],
ProductReviewTransitions::GRAPH => [

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add listener on Symfony workflow also

'callbacks' => [
'after' => [
'setono_sylius_loyalty_award_review_points' => [
'on' => [ProductReviewTransitions::TRANSITION_ACCEPT],
'do' => [$reviewAward, 'onWinzouReviewAccepted'],
'args' => ['object'],
],
],
],
],
]);
}
}
8 changes: 8 additions & 0 deletions src/Doctrine/ORM/LoyaltyAccountRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ public function findOneByCustomerAndChannel(CustomerInterface $customer, Channel
return $account;
}

public function findByCustomer(CustomerInterface $customer): array
{
$accounts = $this->findBy(['customer' => $customer]);
Assert::allIsInstanceOf($accounts, LoyaltyAccountInterface::class);

return array_values($accounts);
}

public function findWithLotsExpiringAtOrBefore(\DateTimeInterface $asOf): array
{
$accounts = $this->createQueryBuilder('account')
Expand Down
32 changes: 32 additions & 0 deletions src/Earning/DefaultTriggerChannelResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusLoyaltyPlugin\Earning;

use Setono\SyliusLoyaltyPlugin\Repository\LoyaltyAccountRepositoryInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\CustomerInterface;

/**
* Resolves the channels where the customer already holds a loyalty account. For a single-channel store
* this is the one channel; a customer with no account yet earns nothing from context-less triggers
* (they earn once they have an account, e.g. after their first order).
*/
final class DefaultTriggerChannelResolver implements TriggerChannelResolverInterface
{
public function __construct(
private readonly LoyaltyAccountRepositoryInterface $accountRepository,
) {
}

public function resolve(CustomerInterface $customer): iterable
{
foreach ($this->accountRepository->findByCustomer($customer) as $account) {
$channel = $account->getChannel();
if ($channel instanceof ChannelInterface) {
yield $channel;
}
}
}
}
58 changes: 58 additions & 0 deletions src/Earning/Trigger/ReviewApprovedStateMachineListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusLoyaltyPlugin\Earning\Trigger;

use Setono\SyliusLoyaltyPlugin\Earning\ActionPointsAwarderInterface;
use Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\ProductReviewInterface;
use Symfony\Component\Workflow\Event\CompletedEvent;

/**
* Awards the `product_review_approved` rules when a product review is accepted. Approval happens in
* admin (no shop channel), so the channels are resolved from the author's loyalty accounts. Bridges
* both state-machine adapters, like the order-award listener; the ledger's idempotency covers overlap.
*/
final class ReviewApprovedStateMachineListener
{
public const TRIGGER = 'product_review_approved';

public function __construct(
private readonly ActionPointsAwarderInterface $awarder,
private readonly TriggerChannelResolverInterface $channelResolver,
) {
}

public function onWorkflowReviewAccepted(CompletedEvent $event): void
{
$subject = $event->getSubject();
if ($subject instanceof ProductReviewInterface) {
$this->award($subject);
}
}

public function onWinzouReviewAccepted(ProductReviewInterface $review): void
{
$this->award($review);
}

private function award(ProductReviewInterface $review): void
{
$author = $review->getAuthor();
if (!$author instanceof CustomerInterface) {
return;
}

$reviewId = (int) $review->getId();
foreach ($this->channelResolver->resolve($author) as $channel) {
$this->awarder->award(
$author,
$channel,
self::TRIGGER,
sprintf('%s:%d:%d', self::TRIGGER, $reviewId, (int) $channel->getId()),
);
}
}
}
20 changes: 20 additions & 0 deletions src/Earning/TriggerChannelResolverInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusLoyaltyPlugin\Earning;

use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\CustomerInterface;

/**
* Resolves which channel(s) an action trigger without a shop-channel context (review approval,
* birthday) should award on. An extension point: tag a replacement to change the strategy.
*/
interface TriggerChannelResolverInterface
{
/**
* @return iterable<ChannelInterface>
*/
public function resolve(CustomerInterface $customer): iterable;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we miss more implementations than the DefaultTriggerChannelResolver. It should probably be a composite service? See https://github.com/Setono/composite-compiler-pass

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — TriggerChannelResolverInterface is now a composite via setono/composite-compiler-pass. CompositeTriggerChannelResolver merges every service tagged setono_sylius_loyalty.trigger_channel_resolver (de-duplicated by channel code); DefaultTriggerChannelResolver is one tagged contributor, the interface aliases the composite, and the interface is registered for autoconfiguration so a project's own resolver is tagged automatically.

}
5 changes: 5 additions & 0 deletions src/Repository/LoyaltyAccountRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ interface LoyaltyAccountRepositoryInterface extends RepositoryInterface
{
public function findOneByCustomerAndChannel(CustomerInterface $customer, ChannelInterface $channel): ?LoyaltyAccountInterface;

/**
* @return list<LoyaltyAccountInterface>
*/
public function findByCustomer(CustomerInterface $customer): array;

/**
* The accounts holding at least one lot that has expired on or before $asOf and does not yet have an
* expire row — i.e. the accounts the expire-points command still needs to process.
Expand Down
15 changes: 15 additions & 0 deletions src/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,21 @@
<tag name="kernel.event_subscriber" />
</service>

<!-- Resolves which channels a context-less action trigger awards on (the customer's account channels) -->
<service id="Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver">
<argument type="service" id="setono_sylius_loyalty.repository.account" />
</service>
<service id="Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface"
alias="Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver" />

<!-- Action trigger: awards the product_review_approved rules when a review is accepted (both
adapters; workflow tag here, winzou callback via the extension's prepend) -->
<service id="Setono\SyliusLoyaltyPlugin\Earning\Trigger\ReviewApprovedStateMachineListener" public="true">
<argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\ActionPointsAwarderInterface" />
<argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface" />
<tag name="kernel.event_listener" event="workflow.sylius_product_review.completed.accept" method="onWorkflowReviewAccepted" />

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this event be added as an event subscriber instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It intentionally matches the order-award and redemption state-machine listeners: an event_listener tag for the workflow transition (workflow.sylius_product_review.completed.accept) plus a winzou callback registered in the extension prepend. Making only this one a subscriber would diverge from those two. If you'd prefer subscribers for all the state-machine bridges, I'm happy to convert the three together as a separate cleanup — let me know.

</service>

<service id="Setono\SyliusLoyaltyPlugin\Message\Handler\AwardOrderPointsHandler">
<argument type="service" id="sylius.repository.order" />
<argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\OrderPointsAwarderInterface" />
Expand Down
143 changes: 143 additions & 0 deletions tests/Functional/DefaultTriggerChannelResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusLoyaltyPlugin\Tests\Functional;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ObjectRepository;
use Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface;
use Setono\SyliusLoyaltyPlugin\Model\LoyaltyAccount;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Currency\Model\CurrencyInterface;
use Sylius\Component\Locale\Model\LocaleInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\Component\Resource\Model\ResourceInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class DefaultTriggerChannelResolverTest extends KernelTestCase
{
private EntityManagerInterface $manager;

private TriggerChannelResolverInterface $resolver;

protected function setUp(): void
{
self::bootKernel();

$manager = self::getContainer()->get('doctrine.orm.entity_manager');
\assert($manager instanceof EntityManagerInterface);
$this->manager = $manager;

$resolver = self::getContainer()->get(\Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver::class);
\assert($resolver instanceof TriggerChannelResolverInterface);
$this->resolver = $resolver;
}

/**
* @test
*/
public function it_resolves_the_channels_where_the_customer_has_an_account(): void
{
$customer = $this->createCustomer('resolver@example.com');
$this->createAccount($customer, $this->createChannel('resolver-a'));
$this->createAccount($customer, $this->createChannel('resolver-b'));

$codes = [];
foreach ($this->resolver->resolve($customer) as $channel) {
$codes[] = $channel->getCode();
}

self::assertCount(2, $codes);
self::assertContains('resolver-a', $codes);
self::assertContains('resolver-b', $codes);
}

/**
* @test
*/
public function it_resolves_nothing_for_a_customer_without_an_account(): void
{
$customer = $this->createCustomer('resolver-empty@example.com');

$channels = [];
foreach ($this->resolver->resolve($customer) as $channel) {
$channels[] = $channel;
}

self::assertCount(0, $channels);
}

private function createAccount(CustomerInterface $customer, ChannelInterface $channel): void
{
$account = new LoyaltyAccount();
$account->setCustomer($customer);
$account->setChannel($channel);
$this->manager->persist($account);
$this->manager->flush();
}

private function createChannel(string $code): ChannelInterface
{
$currency = $this->getOrCreateByCode('sylius.repository.currency', 'sylius.factory.currency', 'USD', CurrencyInterface::class);
\assert($currency instanceof CurrencyInterface);
$locale = $this->getOrCreateByCode('sylius.repository.locale', 'sylius.factory.locale', 'en_US', LocaleInterface::class);
\assert($locale instanceof LocaleInterface);

$channel = $this->factory('sylius.factory.channel')->createNew();
\assert($channel instanceof ChannelInterface);
$channel->setCode($code);
$channel->setName('Web');
$channel->setBaseCurrency($currency);
$channel->setDefaultLocale($locale);
$channel->addCurrency($currency);
$channel->addLocale($locale);
$this->manager->persist($channel);
$this->manager->flush();

return $channel;
}

private function createCustomer(string $email): CustomerInterface
{
$customer = $this->factory('sylius.factory.customer')->createNew();
\assert($customer instanceof CustomerInterface);
$customer->setEmail($email);
$this->manager->persist($customer);
$this->manager->flush();

return $customer;
}

private function getOrCreateByCode(string $repositoryId, string $factoryId, string $code, string $type): ResourceInterface
{
$repository = self::getContainer()->get($repositoryId);
\assert($repository instanceof ObjectRepository);

$resource = $repository->findOneBy(['code' => $code]);
if ($resource instanceof $type) {
\assert($resource instanceof ResourceInterface);

return $resource;
}

$resource = $this->factory($factoryId)->createNew();
\assert(method_exists($resource, 'setCode'));
$resource->setCode($code);
$this->manager->persist($resource);

return $resource;
}

/**
* @return FactoryInterface<ResourceInterface>
*/
private function factory(string $id): FactoryInterface
{
$factory = self::getContainer()->get($id);
\assert($factory instanceof FactoryInterface);

return $factory;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,14 @@ public function it_prepends_winzou_award_and_clawback_callbacks_for_the_relevant
self::assertStringContainsString('setono_sylius_loyalty_clawback_order_points', $encoded);
self::assertStringContainsString('setono_sylius_loyalty_redeem_order_points', $encoded);
self::assertStringContainsString('setono_sylius_loyalty_rollback_redemption', $encoded);
self::assertStringContainsString('sylius_product_review', $encoded);
self::assertStringContainsString('setono_sylius_loyalty_award_review_points', $encoded);
self::assertStringContainsString('"on":["pay"]', $encoded);
self::assertStringContainsString('"on":["fulfill"]', $encoded);
self::assertStringContainsString('"on":["refund"]', $encoded);
self::assertStringContainsString('"on":["cancel"]', $encoded);
self::assertStringContainsString('"on":["complete"]', $encoded);
self::assertStringContainsString('"on":["accept"]', $encoded);
}

/**
Expand Down
Loading
Loading