Skip to content

Commit 0cb2c26

Browse files
LucaGallinariclaude
andcommitted
Recreate Mailchimp ecommerce customer on email change
Mailchimp rejects any cart/order sync that embeds a new email address for an existing ecommerce customer id (400 "An email address may not be changed once a customer is created"). The existing email-change flow only fixed the audience member, leaving the ecommerce customer stale. On email change a new EcommerceCustomerEmailChange message is now dispatched: its handler removes the ecommerce customer from every configured store together with the customer's open Mailchimp carts (resetting mailchimpCartId), so the next cart/order sync recreates everything with the same id and the new email. Transient client failures are rethrown for Messenger retries; the removal is idempotent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0b89c4f commit 0cb2c26

10 files changed

Lines changed: 377 additions & 12 deletions

File tree

config/services/message_handler.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Webgriffe\SyliusMailchimpPlugin\MessageHandler\Cart\CartCreateHandler;
88
use Webgriffe\SyliusMailchimpPlugin\MessageHandler\Cart\CartRemoveHandler;
99
use Webgriffe\SyliusMailchimpPlugin\MessageHandler\Cart\CartUpdateHandler;
10+
use Webgriffe\SyliusMailchimpPlugin\MessageHandler\EcommerceCustomer\EcommerceCustomerEmailChangeHandler;
1011
use Webgriffe\SyliusMailchimpPlugin\MessageHandler\Member\MemberCreateHandler;
1112
use Webgriffe\SyliusMailchimpPlugin\MessageHandler\Member\MemberRemoveHandler;
1213
use Webgriffe\SyliusMailchimpPlugin\MessageHandler\Member\MemberSubscriptionUpdateHandler;
@@ -146,5 +147,16 @@
146147
->arg('$mailchimpClient', service('Webgriffe\SyliusMailchimpPlugin\Client\MailchimpClientInterface'))
147148
->arg('$logger', service('monolog.logger.mailchimp'))
148149
->tag('messenger.message_handler');
150+
151+
$services->set(EcommerceCustomerEmailChangeHandler::class)
152+
->arg('$customerRepository', service('sylius.repository.customer'))
153+
->arg('$channelRepository', service('sylius.repository.channel'))
154+
->arg('$orderRepository', service('sylius.repository.order'))
155+
->arg('$audienceProvider', service(AudienceProviderInterface::class))
156+
->arg('$storeIdentifierResolver', service(StoreIdentifierResolverInterface::class))
157+
->arg('$mailchimpClient', service('Webgriffe\SyliusMailchimpPlugin\Client\MailchimpClientInterface'))
158+
->arg('$entityManager', service('doctrine.orm.default_entity_manager'))
159+
->arg('$logger', service('monolog.logger.mailchimp'))
160+
->tag('messenger.message_handler');
149161
};
150162

features/newsletter_synchronization_on_profile_update.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Feature: Mailchimp newsletter synchronization on customer profile update
3131
And I save my changes
3232
Then I should be notified that it has been successfully edited
3333
And the Mailchimp member with email "john@example.com" should have been removed
34+
And the Mailchimp ecommerce customer should have been removed
3435
And the customer with email "john.new@example.com" should be synced to Mailchimp
3536

3637
Scenario: Non-subscribed customer updates their profile
@@ -50,4 +51,5 @@ Feature: Mailchimp newsletter synchronization on customer profile update
5051
And I save my changes
5152
Then I should be notified that it has been successfully edited
5253
And the Mailchimp member with email "john@example.com" should have been removed
54+
And the Mailchimp ecommerce customer should have been removed
5355
And the customer should not be synced to Mailchimp

src/Enqueuer/MemberEnqueuer.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Symfony\Component\Messenger\MessageBusInterface;
1010
use Webgriffe\SyliusMailchimpPlugin\Client\MailchimpClientInterface;
1111
use Webgriffe\SyliusMailchimpPlugin\Exception\AudienceNotFoundException;
12+
use Webgriffe\SyliusMailchimpPlugin\Message\EcommerceCustomer\EcommerceCustomerEmailChange;
1213
use Webgriffe\SyliusMailchimpPlugin\Message\Member\MemberCreate;
1314
use Webgriffe\SyliusMailchimpPlugin\Message\Member\MemberRemove;
1415
use Webgriffe\SyliusMailchimpPlugin\Message\Member\MemberUpdate;
@@ -77,7 +78,6 @@ public function enqueueForList(CustomerInterface $customer, int $customerId, str
7778
]);
7879
$remoteMember = null;
7980
}
80-
8181
if ($remoteMember !== null) {
8282
$this->dispatchSafely(new MemberUpdate($customerId, $listId));
8383
$this->logger->debug('[Mailchimp] Dispatched MemberUpdate for customer #{id} (existing remote member).', ['id' => $customerId]);
@@ -90,6 +90,18 @@ public function enqueueForList(CustomerInterface $customer, int $customerId, str
9090
#[\Override]
9191
public function enqueueEmailChange(CustomerInterface $customer, string $oldEmail): void
9292
{
93+
// The Mailchimp ecommerce customer must be recreated on email change (its email is
94+
// immutable), even for customers that are not newsletter-subscribed and when the
95+
// current channel has no audience configured.
96+
if ($customer instanceof MailchimpAwareInterface) {
97+
/** @var mixed $customerId */
98+
$customerId = $customer->getId();
99+
if (is_int($customerId)) {
100+
$this->dispatchSafely(new EcommerceCustomerEmailChange($customerId));
101+
$this->logger->debug('[Mailchimp] Dispatched EcommerceCustomerEmailChange for customer #{id}.', ['id' => $customerId]);
102+
}
103+
}
104+
93105
$context = $this->resolveAudienceContext($customer, 'email change enqueue');
94106
if ($context === null) {
95107
return;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webgriffe\SyliusMailchimpPlugin\Message\EcommerceCustomer;
6+
7+
final class EcommerceCustomerEmailChange
8+
{
9+
public function __construct(
10+
public readonly int $customerId,
11+
) {
12+
}
13+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webgriffe\SyliusMailchimpPlugin\MessageHandler\EcommerceCustomer;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Psr\Log\LoggerInterface;
9+
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
10+
use Sylius\Component\Core\Model\ChannelInterface;
11+
use Sylius\Component\Core\Model\CustomerInterface;
12+
use Sylius\Component\Core\Model\OrderInterface;
13+
use Sylius\Component\Core\Repository\CustomerRepositoryInterface;
14+
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
15+
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
16+
use Webgriffe\SyliusMailchimpPlugin\Client\MailchimpClientInterface;
17+
use Webgriffe\SyliusMailchimpPlugin\Exception\AudienceNotFoundException;
18+
use Webgriffe\SyliusMailchimpPlugin\Message\EcommerceCustomer\EcommerceCustomerEmailChange;
19+
use Webgriffe\SyliusMailchimpPlugin\Model\ChannelMailchimpAwareInterface;
20+
use Webgriffe\SyliusMailchimpPlugin\Model\MailchimpOrderAwareInterface;
21+
use Webgriffe\SyliusMailchimpPlugin\Provider\AudienceProviderInterface;
22+
use Webgriffe\SyliusMailchimpPlugin\Resolver\StoreIdentifierResolverInterface;
23+
use Webgriffe\SyliusMailchimpPlugin\Util\MailchimpErrorClassifier;
24+
25+
/**
26+
* Mailchimp forbids changing the email address of an existing ecommerce customer, so on
27+
* email change the customer is removed from every configured store together with its open
28+
* carts; the next cart/order sync recreates everything with the same id and the new email.
29+
*/
30+
#[AsMessageHandler]
31+
final class EcommerceCustomerEmailChangeHandler
32+
{
33+
public function __construct(
34+
private readonly CustomerRepositoryInterface $customerRepository,
35+
private readonly ChannelRepositoryInterface $channelRepository,
36+
private readonly OrderRepositoryInterface $orderRepository,
37+
private readonly AudienceProviderInterface $audienceProvider,
38+
private readonly StoreIdentifierResolverInterface $storeIdentifierResolver,
39+
private readonly MailchimpClientInterface $mailchimpClient,
40+
private readonly EntityManagerInterface $entityManager,
41+
private readonly LoggerInterface $logger,
42+
) {
43+
}
44+
45+
public function __invoke(EcommerceCustomerEmailChange $message): void
46+
{
47+
$customer = $this->customerRepository->find($message->customerId);
48+
if (!$customer instanceof CustomerInterface) {
49+
$this->logger->warning('[Mailchimp] Customer #{id} not found, skipping EcommerceCustomerEmailChange.', ['id' => $message->customerId]);
50+
51+
return;
52+
}
53+
54+
foreach ($this->channelRepository->findAll() as $channel) {
55+
if (!$channel instanceof ChannelInterface || !$channel instanceof ChannelMailchimpAwareInterface) {
56+
continue;
57+
}
58+
59+
try {
60+
$audience = $this->audienceProvider->getAudience($channel);
61+
} catch (AudienceNotFoundException $e) {
62+
$this->logger->debug('[Mailchimp] No audience for channel #{id}, skipping EcommerceCustomerEmailChange: {msg}', [
63+
'id' => $channel->getId(),
64+
'msg' => $e->getMessage(),
65+
]);
66+
67+
continue;
68+
}
69+
70+
try {
71+
$storeId = $this->storeIdentifierResolver->resolve($audience);
72+
$this->mailchimpClient->removeEcommerceCustomer($storeId, (string) $message->customerId);
73+
$this->removeOpenCarts($customer, $channel, $storeId);
74+
$this->logger->info('[Mailchimp] Ecommerce customer #{id} removed from store {store} after email change.', [
75+
'id' => $message->customerId,
76+
'store' => $storeId,
77+
]);
78+
} catch (\Throwable $e) {
79+
$this->logger->error('[Mailchimp] Failed to remove ecommerce customer #{id} from channel #{channel}: {msg}', [
80+
'id' => $message->customerId,
81+
'channel' => $channel->getId(),
82+
'msg' => $e->getMessage(),
83+
]);
84+
if (!MailchimpErrorClassifier::isPermanent($e)) {
85+
// DELETE calls are idempotent, retrying the whole message is safe.
86+
throw $e;
87+
}
88+
}
89+
}
90+
}
91+
92+
private function removeOpenCarts(CustomerInterface $customer, ChannelInterface $channel, string $storeId): void
93+
{
94+
$carts = $this->orderRepository->findBy([
95+
'customer' => $customer,
96+
'channel' => $channel,
97+
'state' => OrderInterface::STATE_CART,
98+
]);
99+
100+
$needsFlush = false;
101+
foreach ($carts as $cart) {
102+
if (!$cart instanceof MailchimpOrderAwareInterface) {
103+
continue;
104+
}
105+
106+
$mailchimpCartId = $cart->getMailchimpCartId();
107+
if ($mailchimpCartId === null || $mailchimpCartId === '') {
108+
continue;
109+
}
110+
111+
$this->mailchimpClient->removeCart($storeId, $mailchimpCartId);
112+
$cart->setMailchimpCartId(null);
113+
$needsFlush = true;
114+
}
115+
116+
if ($needsFlush) {
117+
$this->entityManager->flush();
118+
}
119+
}
120+
}

tests/Behat/Context/Ui/Shop/MailchimpProfileContext.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ public function noMailchimpMemberShouldHaveBeenRemoved(): void
4141
);
4242
}
4343

44+
/**
45+
* @Then the Mailchimp ecommerce customer should have been removed
46+
*/
47+
public function theMailchimpEcommerceCustomerShouldHaveBeenRemoved(): void
48+
{
49+
Assert::notEmpty(
50+
$this->stubMailchimpClient->getRemoveEcommerceCustomerCalls(),
51+
'Expected removeEcommerceCustomer to be called, but it was not.',
52+
);
53+
}
54+
4455
/**
4556
* @Then the customer with email :email should be synced to Mailchimp
4657
*/
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
Sylius\Component\Locale\Model\Locale:
2+
locale_en_us:
3+
code: 'en_US'
4+
5+
Sylius\Component\Currency\Model\Currency:
6+
currency_usd:
7+
code: 'USD'
8+
9+
Tests\Webgriffe\SyliusMailchimpPlugin\Entity\Channel\Channel:
10+
channel_email_change:
11+
code: 'EMAIL_CHANGE_TEST'
12+
name: 'Email Change Test Store'
13+
hostname: 'https://email-change.test'
14+
mailchimpAudienceId: 'test-audience-id'
15+
taxCalculationStrategy: 'order_items_based'
16+
defaultLocale: '@locale_en_us'
17+
baseCurrency: '@currency_usd'
18+
__calls:
19+
- addLocale: ['@locale_en_us']
20+
- addCurrency: ['@currency_usd']
21+
22+
Tests\Webgriffe\SyliusMailchimpPlugin\Entity\Customer\Customer:
23+
customer_email_change:
24+
email: 'email-change@test.com'
25+
26+
Tests\Webgriffe\SyliusMailchimpPlugin\Entity\Order\Order:
27+
order_email_change:
28+
channel: '@channel_email_change'
29+
currencyCode: 'USD'
30+
localeCode: 'en_US'
31+
customer: '@customer_email_change'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
Sylius\Component\Locale\Model\Locale:
2+
locale_en_us:
3+
code: 'en_US'
4+
5+
Sylius\Component\Currency\Model\Currency:
6+
currency_usd:
7+
code: 'USD'
8+
9+
Tests\Webgriffe\SyliusMailchimpPlugin\Entity\Channel\Channel:
10+
channel_email_change_no_audience:
11+
code: 'EMAIL_CHANGE_NO_AUDIENCE'
12+
name: 'Email Change No Audience Store'
13+
hostname: 'https://email-change-no-audience.test'
14+
taxCalculationStrategy: 'order_items_based'
15+
defaultLocale: '@locale_en_us'
16+
baseCurrency: '@currency_usd'
17+
__calls:
18+
- addLocale: ['@locale_en_us']
19+
- addCurrency: ['@currency_usd']
20+
21+
Tests\Webgriffe\SyliusMailchimpPlugin\Entity\Customer\Customer:
22+
customer_email_change_no_audience:
23+
email: 'email-change-no-audience@test.com'
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Webgriffe\SyliusMailchimpPlugin\Integration\MessageHandler\EcommerceCustomer;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Fidry\AliceDataFixtures\LoaderInterface;
9+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
10+
use Tests\Webgriffe\SyliusMailchimpPlugin\Entity\Customer\Customer;
11+
use Tests\Webgriffe\SyliusMailchimpPlugin\Entity\Order\Order;
12+
use Tests\Webgriffe\SyliusMailchimpPlugin\Stub\Mailchimp\StubMailchimpClient;
13+
use Webgriffe\SyliusMailchimpPlugin\Client\Exception\ClientException;
14+
use Webgriffe\SyliusMailchimpPlugin\Message\EcommerceCustomer\EcommerceCustomerEmailChange;
15+
use Webgriffe\SyliusMailchimpPlugin\MessageHandler\EcommerceCustomer\EcommerceCustomerEmailChangeHandler;
16+
17+
final class EcommerceCustomerEmailChangeHandlerTest extends KernelTestCase
18+
{
19+
private const FIXTURE_BASE_DIR = __DIR__ . '/../../../DataFixtures/ORM/resources/MessageHandler/EcommerceCustomer/EcommerceCustomerEmailChangeHandlerTest';
20+
21+
private LoaderInterface $fixtureLoader;
22+
23+
private StubMailchimpClient $stub;
24+
25+
protected function setUp(): void
26+
{
27+
static::bootKernel();
28+
$this->fixtureLoader = self::getContainer()->get('fidry_alice_data_fixtures.doctrine.loader');
29+
$this->stub = self::getContainer()->get(StubMailchimpClient::class);
30+
$this->stub->reset();
31+
}
32+
33+
public function test_it_removes_ecommerce_customer_from_configured_store(): void
34+
{
35+
$this->fixtureLoader->load([self::FIXTURE_BASE_DIR . '/customer.yaml']);
36+
37+
$em = self::getContainer()->get(EntityManagerInterface::class);
38+
$customer = $em->getRepository(Customer::class)->findOneBy(['email' => 'email-change@test.com']);
39+
40+
$handler = self::getContainer()->get(EcommerceCustomerEmailChangeHandler::class);
41+
$handler(new EcommerceCustomerEmailChange($customer->getId()));
42+
43+
$calls = $this->stub->getRemoveEcommerceCustomerCalls();
44+
self::assertCount(1, $calls);
45+
self::assertSame((string) $customer->getId(), $calls[0]['customerId']);
46+
}
47+
48+
public function test_it_removes_open_carts_and_resets_mailchimp_cart_id(): void
49+
{
50+
$this->fixtureLoader->load([self::FIXTURE_BASE_DIR . '/customer.yaml']);
51+
52+
$em = self::getContainer()->get(EntityManagerInterface::class);
53+
$customer = $em->getRepository(Customer::class)->findOneBy(['email' => 'email-change@test.com']);
54+
$order = $em->getRepository(Order::class)->findOneBy(['customer' => $customer]);
55+
$order->setMailchimpCartId((string) $order->getId());
56+
$em->flush();
57+
58+
$handler = self::getContainer()->get(EcommerceCustomerEmailChangeHandler::class);
59+
$handler(new EcommerceCustomerEmailChange($customer->getId()));
60+
61+
self::assertCount(1, $this->stub->getRemoveCartCalls());
62+
$em->refresh($order);
63+
self::assertNull($order->getMailchimpCartId());
64+
}
65+
66+
public function test_it_skips_channels_without_audience(): void
67+
{
68+
$this->fixtureLoader->load([self::FIXTURE_BASE_DIR . '/customer_without_audience.yaml']);
69+
70+
$em = self::getContainer()->get(EntityManagerInterface::class);
71+
$customer = $em->getRepository(Customer::class)->findOneBy(['email' => 'email-change-no-audience@test.com']);
72+
73+
$handler = self::getContainer()->get(EcommerceCustomerEmailChangeHandler::class);
74+
$handler(new EcommerceCustomerEmailChange($customer->getId()));
75+
76+
self::assertCount(0, $this->stub->getRemoveEcommerceCustomerCalls());
77+
}
78+
79+
public function test_it_skips_when_customer_not_found(): void
80+
{
81+
$this->fixtureLoader->load([]);
82+
83+
$handler = self::getContainer()->get(EcommerceCustomerEmailChangeHandler::class);
84+
$handler(new EcommerceCustomerEmailChange(99999));
85+
86+
self::assertCount(0, $this->stub->getRemoveEcommerceCustomerCalls());
87+
}
88+
89+
public function test_it_throws_on_transient_client_failure(): void
90+
{
91+
$this->fixtureLoader->load([self::FIXTURE_BASE_DIR . '/customer.yaml']);
92+
93+
$em = self::getContainer()->get(EntityManagerInterface::class);
94+
$customer = $em->getRepository(Customer::class)->findOneBy(['email' => 'email-change@test.com']);
95+
$this->stub->failWith('removeEcommerceCustomer');
96+
97+
$handler = self::getContainer()->get(EcommerceCustomerEmailChangeHandler::class);
98+
99+
$this->expectException(ClientException::class);
100+
$handler(new EcommerceCustomerEmailChange($customer->getId()));
101+
}
102+
103+
public function test_it_does_not_throw_on_permanent_client_failure(): void
104+
{
105+
$this->fixtureLoader->load([self::FIXTURE_BASE_DIR . '/customer.yaml']);
106+
107+
$em = self::getContainer()->get(EntityManagerInterface::class);
108+
$customer = $em->getRepository(Customer::class)->findOneBy(['email' => 'email-change@test.com']);
109+
$this->stub->failWith('removeEcommerceCustomer', statusCode: 400);
110+
111+
$handler = self::getContainer()->get(EcommerceCustomerEmailChangeHandler::class);
112+
$handler(new EcommerceCustomerEmailChange($customer->getId()));
113+
114+
self::assertCount(0, $this->stub->getRemoveEcommerceCustomerCalls());
115+
}
116+
}

0 commit comments

Comments
 (0)