Cron Job: Point Expiry and Tier Recalculation
Cron Job: Point Expiry and Tier Recalculation
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Mironsoft\Loyalty\Cron\ExpirePoints, registered under the job code mironsoft_loyalty_expire_points (chapter 32), is the final building block that automatically retires points once their expires_at (chapter 3) is reached - and runs into the same append-only-ledger limitation again, this time for expiry instead of refunds.
The same problem as chapter 31
The ledger stores one expires_at per row, but no flag for "this row has already been processed". A daily job would otherwise detect the same due earn row as "expired" again every single day. The solution is identical to chapter 31: instead of marking rows, every run recalculates how many points per customer should be due in total, and only books the delta against what's already been expired.
Finding due customers
The first step groups directly on the collection via getSelect() - addFieldToFilter() has no notion of GROUP BY, a deliberate, rare drop down to the underlying Zend_Db_Select object.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Cron;
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Framework\Stdlib\DateTime\DateTime;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger\CollectionFactory;
use Psr\Log\LoggerInterface;
/**
* Daily cron job (mironsoft_loyalty_expire_points, chapter 32): expires due
* points and, by saving the customer, indirectly triggers a tier recalculation
* through LoyaltyTierBackend (chapter 26).
*/
class ExpirePoints
{
/**
* @param CollectionFactory $ledgerCollectionFactory Factory for the ledger entry collection.
* @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists the expire ledger entry.
* @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates a new, unsaved ledger entry.
* @param CustomerRepositoryInterface $customerRepository Loads and saves the customer's points balance.
* @param DateTime $dateTime Magento's date helper, used for the "now" cutoff.
* @param LoggerInterface $logger Logs per-customer failures without aborting the whole run (chapter 34).
*/
public function __construct(
private readonly CollectionFactory $ledgerCollectionFactory,
private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
private readonly CustomerRepositoryInterface $customerRepository,
private readonly DateTime $dateTime,
private readonly LoggerInterface $logger
) {
}
/**
* Cron entry point, referenced as method="execute" in crontab.xml.
*
* @return void
*/
public function execute(): void
{
$now = $this->dateTime->date('Y-m-d H:i:s');
foreach ($this->collectCustomerIdsWithDuePoints($now) as $customerId) {
try {
$this->expireForCustomer($customerId, $now);
} catch (\Throwable $exception) {
$this->logger->error(
sprintf(
'Mironsoft_Loyalty: point expiry failed for customer #%d: %s',
$customerId,
$exception->getMessage()
),
['exception' => $exception]
);
// deliberately continue with the next customer, see chapter 34
}
}
}
/**
* Finds every distinct customer with at least one earn entry due for expiry.
*
* @param string $now Cutoff timestamp in Y-m-d H:i:s format.
* @return int[]
*/
private function collectCustomerIdsWithDuePoints(string $now): array
{
$collection = $this->ledgerCollectionFactory->create();
$collection->addFieldToFilter('type', ['eq' => PointsLedgerInterface::TYPE_EARN]);
$collection->addFieldToFilter('expires_at', ['notnull' => true]);
$collection->addFieldToFilter('expires_at', ['lteq' => $now]);
$collection->addFieldToSelect('customer_id');
$collection->getSelect()->group('customer_id');
return array_map('intval', $collection->getColumnValues('customer_id'));
}
/**
* Reconciles due vs. already-expired points for one customer and books the delta.
*
* @param int $customerId Customer entity ID.
* @param string $now Cutoff timestamp in Y-m-d H:i:s format.
* @return void
*/
private function expireForCustomer(int $customerId, string $now): void
{
$duePoints = $this->sumPoints($customerId, PointsLedgerInterface::TYPE_EARN, $now);
$alreadyExpired = abs($this->sumPoints($customerId, PointsLedgerInterface::TYPE_EXPIRE));
$pointsToExpire = $duePoints - $alreadyExpired;
if ($pointsToExpire <= 0) {
return; // already fully expired in an earlier run of this job
}
$customer = $this->customerRepository->getById($customerId);
$currentAttribute = $customer->getCustomAttribute('loyalty_points_balance');
$currentBalance = $currentAttribute !== null ? (int) $currentAttribute->getValue() : 0;
$pointsToExpire = min($pointsToExpire, $currentBalance);
if ($pointsToExpire <= 0) {
return; // balance already reduced below the due amount, e.g. by a redemption
}
$newBalance = $currentBalance - $pointsToExpire;
$ledgerEntry = $this->pointsLedgerFactory->create();
$ledgerEntry->setCustomerId($customerId);
$ledgerEntry->setType(PointsLedgerInterface::TYPE_EXPIRE);
$ledgerEntry->setPoints(-$pointsToExpire);
$ledgerEntry->setBalanceAfter($newBalance);
$this->pointsLedgerRepository->save($ledgerEntry);
$customer->setCustomAttribute('loyalty_points_balance', $newBalance);
// LoyaltyTierBackend (chapter 26) recalculates loyalty_tier automatically
// from the new balance - no tier logic duplicated here, same as chapter 30.
$this->customerRepository->save($customer);
}
/**
* Sums ledger points of a given type for a customer, optionally only entries
* whose expires_at is at or before a cutoff.
*
* @param int $customerId Customer entity ID.
* @param string $type One of the PointsLedgerInterface::TYPE_* constants.
* @param string|null $expiresBefore Optional expires_at cutoff in Y-m-d H:i:s format.
* @return int
*/
private function sumPoints(int $customerId, string $type, ?string $expiresBefore = null): int
{
$collection = $this->ledgerCollectionFactory->create();
$collection->addCustomerFilter($customerId);
$collection->addFieldToFilter('type', ['eq' => $type]);
if ($expiresBefore !== null) {
$collection->addFieldToFilter('expires_at', ['lteq' => $expiresBefore]);
}
return (int) array_sum($collection->getColumnValues('points'));
}
}Why no registration in di.xml?
Unlike a console command (chapter 9), a cron job class needs no entry in di.xml - the link from job code to class and method is created entirely by the instance/method attribute pair in crontab.xml (chapter 32). The class itself doesn't even need to implement an interface; Magento calls the configured method via reflection.
Tipp: The same reconciliation pattern from chapter 31 ("what should be booked in total, minus what's already booked") carries over here a second time - not a coincidence: both chapters solve the same structural problem of an append-only ledger with no reference column. Once a pattern like this shows up twice, it's worth keeping in mind as a named technique instead of reinventing it a third time.
Achtung: min($pointsToExpire, $currentBalance) is not a minor detail: without this cap, the calculated expiry amount could push the actual balance below zero - for example if the customer has meanwhile already redeemed points for a reward (redemption, implemented starting block 6) and therefore holds fewer points than purely mathematically "due". A negative loyalty_points_balance must never occur.
The job itself now runs correctly - but what happens if expireForCustomer() keeps failing for a single customer? Chapter 34 dives into exactly that question.