Cronjob: Punkte-Ablauf und Tier-Neuberechnung
Cronjob: Punkte-Ablauf und Tier-Neuberechnung
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Mironsoft\Loyalty\Cron\ExpirePoints, registriert unter dem Job-Code mironsoft_loyalty_expire_points (Kapitel 32), ist der letzte Baustein, der Punkte automatisch aus dem Umlauf nimmt, sobald ihr expires_at (Kapitel 3) erreicht ist - und stößt dabei erneut auf die Grenzen des append-only-Ledgers, diesmal beim Ablauf statt bei der Rückerstattung.
Dasselbe Problem wie in Kapitel 31
Der Ledger speichert pro Zeile ein expires_at, aber keine Markierung "diese Zeile wurde bereits verarbeitet". Ein täglich laufender Job würde dieselbe fällige earn-Zeile sonst jeden Tag erneut als "abgelaufen" erkennen. Die Lösung ist identisch zu Kapitel 31: statt zeilenweise zu markieren, wird bei jedem Lauf neu berechnet, wie viele Punkte pro Kunde insgesamt fällig wären, und nur die Differenz zum bereits verbuchten Verfall tatsächlich abgezogen.
Fällige Kunden ermitteln
Der erste Schritt gruppiert über getSelect() direkt auf der Collection - addFieldToFilter() kennt kein GROUP BY, ein bewusster, seltener Griff zum darunterliegenden Zend_Db_Select-Objekt.
<?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'));
}
}Warum keine Registrierung in di.xml?
Anders als ein Console-Command (Kapitel 9) braucht eine Cronjob-Klasse keinen Eintrag in di.xml - die Verbindung von Job-Code zu Klasse und Methode entsteht ausschließlich über das instance/method-Attributpaar in crontab.xml (Kapitel 32). Die Klasse selbst braucht auch kein Interface zu implementieren; Magento ruft die konfigurierte Methode per Reflection auf.
Tipp: Dasselbe Soll-Ist-Muster aus Kapitel 31 ("was müsste insgesamt gebucht sein, minus was bereits gebucht wurde") trägt hier ein zweites Mal - kein Zufall: beide Kapitel lösen dasselbe strukturelle Problem des append-only-Ledgers ohne Referenzspalte. Ein wiederkehrendes Muster wie dieses lohnt sich, sobald es zweimal auftritt, als benannte Technik im Kopf zu behalten statt es beim dritten Mal neu zu erfinden.
Achtung: min($pointsToExpire, $currentBalance) ist kein Nebensächlichkeit: ohne diese Begrenzung könnte der berechnete Verfallsbetrag den tatsächlichen Kontostand unterschreiten lassen - etwa wenn der Kunde zwischenzeitlich bereits Punkte gegen eine Prämie eingelöst hat (Redemption, ab Block 6 implementiert) und dadurch weniger Punkte besitzt, als rein rechnerisch "fällig" wären. Ein negativer loyalty_points_balance darf niemals entstehen.
Der Job selbst läuft jetzt korrekt - aber was passiert, wenn expireForCustomer() für einen einzelnen Kunden dauerhaft fehlschlägt? Kapitel 34 vertieft genau diese Frage.