Skip to content

Commit 754912b

Browse files
committed
Add the product-review-approved earning trigger + channel resolver
The second action trigger, plus the channel-resolution the context-less triggers need. - TriggerChannelResolverInterface (+ DefaultTriggerChannelResolver) resolves the channels a context-less trigger awards on: the customer's existing loyalty-account channels. It's an extension point, and LoyaltyAccountRepository gained findByCustomer for it. - ReviewApprovedStateMachineListener awards the `product_review_approved` rules when a product review is accepted, on each resolved channel. Approval happens in admin (no shop channel), so the resolver is used instead of the current channel. Bridges both state-machine adapters like the order-award listener (workflow tag + winzou callback via the extension prepend); the ledger's idempotency covers overlap and the per-(review,channel) source identifier. Tests: DefaultTriggerChannelResolverTest (functional) + ReviewApprovedStateMachineListenerTest (unit, both adapters + guards); the extension test now asserts the review winzou callback. Unit 126 + functional 55 green. No new dependencies (ProductReviewInterface is Core).
1 parent a05b2b9 commit 754912b

10 files changed

Lines changed: 398 additions & 0 deletions

src/DependencyInjection/SetonoSyliusLoyaltyExtension.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66

77
use Setono\SyliusLoyaltyPlugin\Earning\Trigger\AwardOrderPointsStateMachineListener;
88
use Setono\SyliusLoyaltyPlugin\Earning\Trigger\ClawbackOrderPointsStateMachineListener;
9+
use Setono\SyliusLoyaltyPlugin\Earning\Trigger\ReviewApprovedStateMachineListener;
910
use Setono\SyliusLoyaltyPlugin\Redemption\RedemptionStateMachineListener;
1011
use Setono\SyliusLoyaltyPlugin\Rule\Amount\EarningAmountInterface;
1112
use Setono\SyliusLoyaltyPlugin\Rule\Condition\EarningConditionInterface;
1213
use Sylius\Bundle\ResourceBundle\DependencyInjection\Extension\AbstractResourceExtension;
1314
use Sylius\Bundle\ResourceBundle\SyliusResourceBundle;
1415
use Sylius\Component\Core\OrderCheckoutTransitions;
1516
use Sylius\Component\Core\OrderPaymentTransitions;
17+
use Sylius\Component\Core\ProductReviewTransitions;
1618
use Sylius\Component\Order\OrderTransitions;
1719
use Symfony\Component\Config\FileLocator;
1820
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -108,6 +110,7 @@ private function registerWinzouCallbacks(ContainerBuilder $container): void
108110
$award = '@' . AwardOrderPointsStateMachineListener::class;
109111
$clawback = '@' . ClawbackOrderPointsStateMachineListener::class;
110112
$redemption = '@' . RedemptionStateMachineListener::class;
113+
$reviewAward = '@' . ReviewApprovedStateMachineListener::class;
111114

112115
$container->prependExtensionConfig('winzou_state_machine', [
113116
OrderPaymentTransitions::GRAPH => [
@@ -158,6 +161,17 @@ private function registerWinzouCallbacks(ContainerBuilder $container): void
158161
],
159162
],
160163
],
164+
ProductReviewTransitions::GRAPH => [
165+
'callbacks' => [
166+
'after' => [
167+
'setono_sylius_loyalty_award_review_points' => [
168+
'on' => [ProductReviewTransitions::TRANSITION_ACCEPT],
169+
'do' => [$reviewAward, 'onWinzouReviewAccepted'],
170+
'args' => ['object'],
171+
],
172+
],
173+
],
174+
],
161175
]);
162176
}
163177
}

src/Doctrine/ORM/LoyaltyAccountRepository.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ public function findOneByCustomerAndChannel(CustomerInterface $customer, Channel
2323
return $account;
2424
}
2525

26+
public function findByCustomer(CustomerInterface $customer): array
27+
{
28+
$accounts = $this->findBy(['customer' => $customer]);
29+
Assert::allIsInstanceOf($accounts, LoyaltyAccountInterface::class);
30+
31+
return array_values($accounts);
32+
}
33+
2634
public function findWithLotsExpiringAtOrBefore(\DateTimeInterface $asOf): array
2735
{
2836
$accounts = $this->createQueryBuilder('account')
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusLoyaltyPlugin\Earning;
6+
7+
use Setono\SyliusLoyaltyPlugin\Repository\LoyaltyAccountRepositoryInterface;
8+
use Sylius\Component\Core\Model\ChannelInterface;
9+
use Sylius\Component\Core\Model\CustomerInterface;
10+
11+
/**
12+
* Resolves the channels where the customer already holds a loyalty account. For a single-channel store
13+
* this is the one channel; a customer with no account yet earns nothing from context-less triggers
14+
* (they earn once they have an account, e.g. after their first order).
15+
*/
16+
final class DefaultTriggerChannelResolver implements TriggerChannelResolverInterface
17+
{
18+
public function __construct(
19+
private readonly LoyaltyAccountRepositoryInterface $accountRepository,
20+
) {
21+
}
22+
23+
public function resolve(CustomerInterface $customer): iterable
24+
{
25+
foreach ($this->accountRepository->findByCustomer($customer) as $account) {
26+
$channel = $account->getChannel();
27+
if ($channel instanceof ChannelInterface) {
28+
yield $channel;
29+
}
30+
}
31+
}
32+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusLoyaltyPlugin\Earning\Trigger;
6+
7+
use Setono\SyliusLoyaltyPlugin\Earning\ActionPointsAwarderInterface;
8+
use Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface;
9+
use Sylius\Component\Core\Model\CustomerInterface;
10+
use Sylius\Component\Core\Model\ProductReviewInterface;
11+
use Symfony\Component\Workflow\Event\CompletedEvent;
12+
13+
/**
14+
* Awards the `product_review_approved` rules when a product review is accepted. Approval happens in
15+
* admin (no shop channel), so the channels are resolved from the author's loyalty accounts. Bridges
16+
* both state-machine adapters, like the order-award listener; the ledger's idempotency covers overlap.
17+
*/
18+
final class ReviewApprovedStateMachineListener
19+
{
20+
public const TRIGGER = 'product_review_approved';
21+
22+
public function __construct(
23+
private readonly ActionPointsAwarderInterface $awarder,
24+
private readonly TriggerChannelResolverInterface $channelResolver,
25+
) {
26+
}
27+
28+
public function onWorkflowReviewAccepted(CompletedEvent $event): void
29+
{
30+
$subject = $event->getSubject();
31+
if ($subject instanceof ProductReviewInterface) {
32+
$this->award($subject);
33+
}
34+
}
35+
36+
public function onWinzouReviewAccepted(ProductReviewInterface $review): void
37+
{
38+
$this->award($review);
39+
}
40+
41+
private function award(ProductReviewInterface $review): void
42+
{
43+
$author = $review->getAuthor();
44+
if (!$author instanceof CustomerInterface) {
45+
return;
46+
}
47+
48+
$reviewId = (int) $review->getId();
49+
foreach ($this->channelResolver->resolve($author) as $channel) {
50+
$this->awarder->award(
51+
$author,
52+
$channel,
53+
self::TRIGGER,
54+
sprintf('%s:%d:%d', self::TRIGGER, $reviewId, (int) $channel->getId()),
55+
);
56+
}
57+
}
58+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusLoyaltyPlugin\Earning;
6+
7+
use Sylius\Component\Core\Model\ChannelInterface;
8+
use Sylius\Component\Core\Model\CustomerInterface;
9+
10+
/**
11+
* Resolves which channel(s) an action trigger without a shop-channel context (review approval,
12+
* birthday) should award on. An extension point: tag a replacement to change the strategy.
13+
*/
14+
interface TriggerChannelResolverInterface
15+
{
16+
/**
17+
* @return iterable<ChannelInterface>
18+
*/
19+
public function resolve(CustomerInterface $customer): iterable;
20+
}

src/Repository/LoyaltyAccountRepositoryInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ interface LoyaltyAccountRepositoryInterface extends RepositoryInterface
1616
{
1717
public function findOneByCustomerAndChannel(CustomerInterface $customer, ChannelInterface $channel): ?LoyaltyAccountInterface;
1818

19+
/**
20+
* @return list<LoyaltyAccountInterface>
21+
*/
22+
public function findByCustomer(CustomerInterface $customer): array;
23+
1924
/**
2025
* The accounts holding at least one lot that has expired on or before $asOf and does not yet have an
2126
* expire row — i.e. the accounts the expire-points command still needs to process.

src/Resources/config/services.xml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,21 @@
117117
<tag name="kernel.event_subscriber" />
118118
</service>
119119

120+
<!-- Resolves which channels a context-less action trigger awards on (the customer's account channels) -->
121+
<service id="Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver">
122+
<argument type="service" id="setono_sylius_loyalty.repository.account" />
123+
</service>
124+
<service id="Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface"
125+
alias="Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver" />
126+
127+
<!-- Action trigger: awards the product_review_approved rules when a review is accepted (both
128+
adapters; workflow tag here, winzou callback via the extension's prepend) -->
129+
<service id="Setono\SyliusLoyaltyPlugin\Earning\Trigger\ReviewApprovedStateMachineListener" public="true">
130+
<argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\ActionPointsAwarderInterface" />
131+
<argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface" />
132+
<tag name="kernel.event_listener" event="workflow.sylius_product_review.completed.accept" method="onWorkflowReviewAccepted" />
133+
</service>
134+
120135
<service id="Setono\SyliusLoyaltyPlugin\Message\Handler\AwardOrderPointsHandler">
121136
<argument type="service" id="sylius.repository.order" />
122137
<argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\OrderPointsAwarderInterface" />
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Setono\SyliusLoyaltyPlugin\Tests\Functional;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Doctrine\Persistence\ObjectRepository;
9+
use Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface;
10+
use Setono\SyliusLoyaltyPlugin\Model\LoyaltyAccount;
11+
use Sylius\Component\Core\Model\ChannelInterface;
12+
use Sylius\Component\Core\Model\CustomerInterface;
13+
use Sylius\Component\Currency\Model\CurrencyInterface;
14+
use Sylius\Component\Locale\Model\LocaleInterface;
15+
use Sylius\Component\Resource\Factory\FactoryInterface;
16+
use Sylius\Component\Resource\Model\ResourceInterface;
17+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
18+
19+
final class DefaultTriggerChannelResolverTest extends KernelTestCase
20+
{
21+
private EntityManagerInterface $manager;
22+
23+
private TriggerChannelResolverInterface $resolver;
24+
25+
protected function setUp(): void
26+
{
27+
self::bootKernel();
28+
29+
$manager = self::getContainer()->get('doctrine.orm.entity_manager');
30+
\assert($manager instanceof EntityManagerInterface);
31+
$this->manager = $manager;
32+
33+
$resolver = self::getContainer()->get('Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver');
34+
\assert($resolver instanceof TriggerChannelResolverInterface);
35+
$this->resolver = $resolver;
36+
}
37+
38+
/**
39+
* @test
40+
*/
41+
public function it_resolves_the_channels_where_the_customer_has_an_account(): void
42+
{
43+
$customer = $this->createCustomer('resolver@example.com');
44+
$this->createAccount($customer, $this->createChannel('resolver-a'));
45+
$this->createAccount($customer, $this->createChannel('resolver-b'));
46+
47+
$codes = [];
48+
foreach ($this->resolver->resolve($customer) as $channel) {
49+
$codes[] = $channel->getCode();
50+
}
51+
52+
self::assertCount(2, $codes);
53+
self::assertContains('resolver-a', $codes);
54+
self::assertContains('resolver-b', $codes);
55+
}
56+
57+
/**
58+
* @test
59+
*/
60+
public function it_resolves_nothing_for_a_customer_without_an_account(): void
61+
{
62+
$customer = $this->createCustomer('resolver-empty@example.com');
63+
64+
$channels = [];
65+
foreach ($this->resolver->resolve($customer) as $channel) {
66+
$channels[] = $channel;
67+
}
68+
69+
self::assertCount(0, $channels);
70+
}
71+
72+
private function createAccount(CustomerInterface $customer, ChannelInterface $channel): void
73+
{
74+
$account = new LoyaltyAccount();
75+
$account->setCustomer($customer);
76+
$account->setChannel($channel);
77+
$this->manager->persist($account);
78+
$this->manager->flush();
79+
}
80+
81+
private function createChannel(string $code): ChannelInterface
82+
{
83+
$currency = $this->getOrCreateByCode('sylius.repository.currency', 'sylius.factory.currency', 'USD', CurrencyInterface::class);
84+
\assert($currency instanceof CurrencyInterface);
85+
$locale = $this->getOrCreateByCode('sylius.repository.locale', 'sylius.factory.locale', 'en_US', LocaleInterface::class);
86+
\assert($locale instanceof LocaleInterface);
87+
88+
$channel = $this->factory('sylius.factory.channel')->createNew();
89+
\assert($channel instanceof ChannelInterface);
90+
$channel->setCode($code);
91+
$channel->setName('Web');
92+
$channel->setBaseCurrency($currency);
93+
$channel->setDefaultLocale($locale);
94+
$channel->addCurrency($currency);
95+
$channel->addLocale($locale);
96+
$this->manager->persist($channel);
97+
$this->manager->flush();
98+
99+
return $channel;
100+
}
101+
102+
private function createCustomer(string $email): CustomerInterface
103+
{
104+
$customer = $this->factory('sylius.factory.customer')->createNew();
105+
\assert($customer instanceof CustomerInterface);
106+
$customer->setEmail($email);
107+
$this->manager->persist($customer);
108+
$this->manager->flush();
109+
110+
return $customer;
111+
}
112+
113+
private function getOrCreateByCode(string $repositoryId, string $factoryId, string $code, string $type): ResourceInterface
114+
{
115+
$repository = self::getContainer()->get($repositoryId);
116+
\assert($repository instanceof ObjectRepository);
117+
118+
$resource = $repository->findOneBy(['code' => $code]);
119+
if ($resource instanceof $type) {
120+
\assert($resource instanceof ResourceInterface);
121+
122+
return $resource;
123+
}
124+
125+
$resource = $this->factory($factoryId)->createNew();
126+
\assert(method_exists($resource, 'setCode'));
127+
$resource->setCode($code);
128+
$this->manager->persist($resource);
129+
130+
return $resource;
131+
}
132+
133+
/**
134+
* @return FactoryInterface<ResourceInterface>
135+
*/
136+
private function factory(string $id): FactoryInterface
137+
{
138+
$factory = self::getContainer()->get($id);
139+
\assert($factory instanceof FactoryInterface);
140+
141+
return $factory;
142+
}
143+
}

tests/Unit/DependencyInjection/SetonoSyliusLoyaltyExtensionTest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,14 @@ public function it_prepends_winzou_award_and_clawback_callbacks_for_the_relevant
6262
self::assertStringContainsString('setono_sylius_loyalty_clawback_order_points', $encoded);
6363
self::assertStringContainsString('setono_sylius_loyalty_redeem_order_points', $encoded);
6464
self::assertStringContainsString('setono_sylius_loyalty_rollback_redemption', $encoded);
65+
self::assertStringContainsString('sylius_product_review', $encoded);
66+
self::assertStringContainsString('setono_sylius_loyalty_award_review_points', $encoded);
6567
self::assertStringContainsString('"on":["pay"]', $encoded);
6668
self::assertStringContainsString('"on":["fulfill"]', $encoded);
6769
self::assertStringContainsString('"on":["refund"]', $encoded);
6870
self::assertStringContainsString('"on":["cancel"]', $encoded);
6971
self::assertStringContainsString('"on":["complete"]', $encoded);
72+
self::assertStringContainsString('"on":["accept"]', $encoded);
7073
}
7174

7275
/**

0 commit comments

Comments
 (0)