Skip to content

Commit fa92239

Browse files
committed
ACP2E-3493: Expired persistent quotes are not cleaned up by a cron job sales_clean_quotes
- Fixed the alternative solution.
1 parent 32e0645 commit fa92239

File tree

8 files changed

+370
-202
lines changed

8 files changed

+370
-202
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Persistent\Model;
9+
10+
use Magento\Framework\Exception\LocalizedException;
11+
use Magento\Store\Model\StoreManagerInterface;
12+
use Magento\Quote\Model\ResourceModel\Quote\Collection as QuoteCollection;
13+
use Magento\Persistent\Model\ResourceModel\ExpiredPersistentQuotesCollection;
14+
use Magento\Customer\Model\Logger as CustomerLogger;
15+
use Magento\Quote\Model\QuoteRepository;
16+
use Psr\Log\LoggerInterface;
17+
use Exception;
18+
19+
/**
20+
* Cleaning expired persistent quotes from the cron
21+
*/
22+
class CleanExpiredPersistentQuotes
23+
{
24+
/**
25+
* @param StoreManagerInterface $storeManager
26+
* @param ExpiredPersistentQuotesCollection $expiredPersistentQuotesCollection
27+
* @param CustomerLogger $customerLogger
28+
* @param QuoteRepository $quoteRepository
29+
* @param LoggerInterface $logger
30+
*/
31+
public function __construct(
32+
private readonly StoreManagerInterface $storeManager,
33+
private readonly ExpiredPersistentQuotesCollection $expiredPersistentQuotesCollection,
34+
private readonly CustomerLogger $customerLogger,
35+
private readonly QuoteRepository $quoteRepository,
36+
private readonly LoggerInterface $logger
37+
) {
38+
}
39+
40+
/**
41+
* Removes expired persistent quotes for a specific website, identified by its ID
42+
*
43+
* @param int $websiteId
44+
* @return void
45+
* @throws LocalizedException
46+
*/
47+
public function execute(int $websiteId): void
48+
{
49+
$stores = $this->storeManager->getWebsite($websiteId)->getStores();
50+
51+
foreach ($stores as $store) {
52+
/** @var $quoteCollection QuoteCollection */
53+
$quoteCollection = $this->expiredPersistentQuotesCollection->getExpiredPersistentQuotes($store);
54+
$quoteCollection->setPageSize(50);
55+
56+
// Last page returns 1 even when we don't have any results
57+
$lastPage = $quoteCollection->getSize() ? $quoteCollection->getLastPageNumber() : 0;
58+
59+
for ($currentPage = $lastPage; $currentPage >= 1; $currentPage--) {
60+
$quoteCollection->setCurPage($currentPage);
61+
62+
$this->deletePersistentQuotes($quoteCollection);
63+
}
64+
}
65+
}
66+
67+
/**
68+
* Deletes all quotes in the collection.
69+
*
70+
* @param QuoteCollection $quoteCollection
71+
*/
72+
private function deletePersistentQuotes(QuoteCollection $quoteCollection): void
73+
{
74+
foreach ($quoteCollection as $quote) {
75+
try {
76+
if (!$this->isLoggedInCustomer((int) $quote->getCustomerId())) {
77+
$this->quoteRepository->delete($quote);
78+
}
79+
} catch (Exception $e) {
80+
$message = sprintf(
81+
'Unable to delete expired quote (ID: %s): %s',
82+
$quote->getId(),
83+
(string)$e
84+
);
85+
$this->logger->error($message);
86+
}
87+
}
88+
89+
$quoteCollection->clear();
90+
}
91+
92+
/**
93+
* Determine if the customer is currently logged in based on their last login and logout timestamps.
94+
*
95+
* @param int $customerId
96+
* @return bool
97+
*/
98+
private function isLoggedInCustomer(int $customerId): bool
99+
{
100+
$isLoggedIn = false;
101+
$customerLastLoginAt = strtotime($this->customerLogger->get($customerId)->getLastLoginAt());
102+
$customerLastLogoutAt = strtotime($this->customerLogger->get($customerId)->getLastLogoutAt());
103+
if ($customerLastLoginAt > $customerLastLogoutAt) {
104+
$isLoggedIn = true;
105+
}
106+
return $isLoggedIn;
107+
}
108+
}

app/code/Magento/Persistent/Model/ResourceModel/DeleteExpiredQuote.php

Lines changed: 0 additions & 59 deletions
This file was deleted.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Persistent\Model\ResourceModel;
9+
10+
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
11+
use Magento\Persistent\Helper\Data;
12+
use Magento\Framework\App\Config\ScopeConfigInterface;
13+
use Magento\Quote\Model\ResourceModel\Quote\Collection;
14+
use Magento\Quote\Model\ResourceModel\Quote\CollectionFactory;
15+
use Magento\Store\Api\Data\StoreInterface;
16+
use Magento\Store\Model\ScopeInterface;
17+
18+
/**
19+
* Handles the collection of expired persistent quotes.
20+
*/
21+
class ExpiredPersistentQuotesCollection
22+
{
23+
/**
24+
* @param ScopeConfigInterface $scopeConfig
25+
* @param CollectionFactory $quoteCollectionFactory
26+
*/
27+
public function __construct(
28+
private readonly ScopeConfigInterface $scopeConfig,
29+
private readonly CollectionFactory $quoteCollectionFactory
30+
) {
31+
}
32+
33+
/**
34+
* Retrieves the collection of expired persistent quotes.
35+
*
36+
* Filters and returns all quotes that have expired based on the persistent lifetime threshold.
37+
*
38+
* @param StoreInterface $store
39+
* @return AbstractCollection
40+
*/
41+
public function getExpiredPersistentQuotes(StoreInterface $store): AbstractCollection
42+
{
43+
$lifetime = $this->scopeConfig->getValue(
44+
Data::XML_PATH_LIFE_TIME,
45+
ScopeInterface::SCOPE_WEBSITE,
46+
$store->getWebsiteId()
47+
);
48+
49+
/** @var $quotes Collection */
50+
$quotes = $this->quoteCollectionFactory->create();
51+
$quotes->addFieldToFilter('main_table.store_id', $store->getId());
52+
$quotes->addFieldToFilter('main_table.updated_at', ['lt' => gmdate("Y-m-d H:i:s", time() - $lifetime)]);
53+
$quotes->addFieldToFilter('main_table.is_persistent', 1);
54+
55+
return $quotes;
56+
}
57+
}

app/code/Magento/Persistent/Observer/ClearExpiredCronJobObserver.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
namespace Magento\Persistent\Observer;
99

1010
use Magento\Cron\Model\Schedule;
11-
use Magento\Persistent\Model\ResourceModel\DeleteExpiredQuote;
11+
use Magento\Persistent\Model\CleanExpiredPersistentQuotes;
1212
use Magento\Persistent\Model\SessionFactory;
1313
use Magento\Store\Model\ResourceModel\Website\CollectionFactory;
1414

@@ -29,25 +29,25 @@ class ClearExpiredCronJobObserver
2929
protected SessionFactory $_sessionFactory;
3030

3131
/**
32-
* A property for delete expired quote factory
32+
* A property for clean expired persistent quotes
3333
*
34-
* @var DeleteExpiredQuote
34+
* @var CleanExpiredPersistentQuotes
3535
*/
36-
private DeleteExpiredQuote $deleteExpiredQuote;
36+
private CleanExpiredPersistentQuotes $cleanExpiredPersistentQuotes;
3737

3838
/**
3939
* @param CollectionFactory $websiteCollectionFactory
4040
* @param SessionFactory $sessionFactory
41-
* @param DeleteExpiredQuote $deleteExpiredQuote
41+
* @param CleanExpiredPersistentQuotes $cleanExpiredPersistentQuotes
4242
*/
4343
public function __construct(
4444
CollectionFactory $websiteCollectionFactory,
4545
SessionFactory $sessionFactory,
46-
DeleteExpiredQuote $deleteExpiredQuote
46+
CleanExpiredPersistentQuotes $cleanExpiredPersistentQuotes
4747
) {
4848
$this->_websiteCollectionFactory = $websiteCollectionFactory;
4949
$this->_sessionFactory = $sessionFactory;
50-
$this->deleteExpiredQuote = $deleteExpiredQuote;
50+
$this->cleanExpiredPersistentQuotes = $cleanExpiredPersistentQuotes;
5151
}
5252

5353
/**
@@ -66,7 +66,7 @@ public function execute(Schedule $schedule)
6666

6767
foreach ($websiteIds as $websiteId) {
6868
$this->_sessionFactory->create()->deleteExpired($websiteId);
69-
$this->deleteExpiredQuote->deleteExpiredQuote((int) $websiteId);
69+
$this->cleanExpiredPersistentQuotes->execute((int) $websiteId);
7070
}
7171

7272
return $this;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Persistent\Test\Unit\Model;
9+
10+
use Magento\Persistent\Model\CleanExpiredPersistentQuotes;
11+
use Magento\Persistent\Model\ResourceModel\ExpiredPersistentQuotesCollection;
12+
use Magento\Customer\Model\Logger as CustomerLogger;
13+
use Magento\Quote\Model\Quote;
14+
use Magento\Quote\Model\QuoteRepository;
15+
use Magento\Store\Model\StoreManagerInterface;
16+
use Magento\Quote\Model\ResourceModel\Quote\Collection;
17+
use PHPUnit\Framework\TestCase;
18+
use Psr\Log\LoggerInterface;
19+
use Magento\Store\Api\Data\StoreInterface;
20+
use Magento\Store\Model\Website;
21+
use Magento\Customer\Model\Log;
22+
23+
class CleanExpiredPersistentQuotesTest extends TestCase
24+
{
25+
/**
26+
* @var StoreManagerInterface
27+
*/
28+
private StoreManagerInterface $storeManagerMock;
29+
30+
/**
31+
* @var ExpiredPersistentQuotesCollection
32+
*/
33+
private ExpiredPersistentQuotesCollection $expiredPersistentQuotesCollectionMock;
34+
35+
/**
36+
* @var CustomerLogger
37+
*/
38+
private CustomerLogger $customerLoggerMock;
39+
40+
/**
41+
* @var QuoteRepository
42+
*/
43+
private QuoteRepository $quoteRepositoryMock;
44+
45+
/**
46+
* @var LoggerInterface
47+
*/
48+
private LoggerInterface $loggerMock;
49+
50+
/**
51+
* @var CleanExpiredPersistentQuotes
52+
*/
53+
private CleanExpiredPersistentQuotes $cleanExpiredPersistentQuotes;
54+
55+
protected function setUp(): void
56+
{
57+
$this->storeManagerMock = $this->createMock(StoreManagerInterface::class);
58+
$this->expiredPersistentQuotesCollectionMock = $this->createMock(ExpiredPersistentQuotesCollection::class);
59+
$this->customerLoggerMock = $this->createMock(CustomerLogger::class);
60+
$this->quoteRepositoryMock = $this->createMock(QuoteRepository::class);
61+
$this->loggerMock = $this->createMock(LoggerInterface::class);
62+
63+
$this->cleanExpiredPersistentQuotes = new CleanExpiredPersistentQuotes(
64+
$this->storeManagerMock,
65+
$this->expiredPersistentQuotesCollectionMock,
66+
$this->customerLoggerMock,
67+
$this->quoteRepositoryMock,
68+
$this->loggerMock
69+
);
70+
}
71+
72+
public function testExecuteDeletesExpiredQuotes(): void
73+
{
74+
$websiteId = 1;
75+
76+
$storeMock = $this->createMock(StoreInterface::class);
77+
$storeMock->method('getId')->willReturn(1);
78+
$storeMock->method('getWebsiteId')->willReturn(2);
79+
80+
$websiteMock = $this->createMock(Website::class);
81+
$websiteMock->method('getStores')->willReturn([$storeMock]);
82+
83+
$this->storeManagerMock->method('getWebsite')
84+
->with($websiteId)
85+
->willReturn($websiteMock);
86+
87+
$quoteCollectionMock = $this->createMock(Collection::class);
88+
$quoteCollectionMock->method('getSize')->willReturn(1); // Simulate that we have expired quotes
89+
$quoteCollectionMock->method('getLastPageNumber')->willReturn(1);
90+
$quoteCollectionMock->method('setPageSize')->willReturnSelf();
91+
$quoteCollectionMock->method('setCurPage')->willReturnSelf();
92+
93+
$this->expiredPersistentQuotesCollectionMock
94+
->method('getExpiredPersistentQuotes')
95+
->with($storeMock)
96+
->willReturn($quoteCollectionMock);
97+
98+
$quoteMock = $this->getMockBuilder(Quote::class)
99+
->disableOriginalConstructor()
100+
->addMethods(['getCustomerId'])
101+
->getMock();
102+
$quoteMock->method('getCustomerId')->willReturn(1);
103+
$quoteCollectionMock->method('getIterator')->willReturn(new \ArrayIterator([$quoteMock]));
104+
105+
$logMock = $this->createMock(Log::class);
106+
$logMock->method('getLastLoginAt')->willReturn('2025-01-01 00:00:00');
107+
$logMock->method('getLastLogoutAt')->willReturn('2025-01-01 10:05:00');
108+
$this->customerLoggerMock->method('get')->willReturn($logMock);
109+
110+
$this->quoteRepositoryMock->expects($this->once())->method('delete');
111+
112+
$this->cleanExpiredPersistentQuotes->execute($websiteId);
113+
}
114+
}

0 commit comments

Comments
 (0)