Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Console Command: Manual Points Recalculation and Ledger Auditing

Console Command: Manual Points Recalculation and Ledger Auditing

~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

An append-only ledger (chapter 3) makes mistakes traceable, but it doesn't prevent them: a failed deploy in the middle of an observer chain, a manual database intervention, a bug in an earlier module version - all of that can cause the values stored in balance_after to no longer match the actual sum of points. This last building block of block 1 is a console command that detects exactly that and - optionally - corrects it.

Command design: mironsoft:loyalty:recalculate

The command reads a customer's (or all customers') ledger entries chronologically, keeps a running sum, and compares it against the stored balance_after value of every row. On a mismatch, it's reported - and, unless --dry-run is set, immediately corrected with a new row of type adjust. Important: an existing row is never modified, only appended to - the principle from chapter 3 applies here without exception.

app/code/Mironsoft/Loyalty/Console/Command/RecalculatePointsCommand.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Console\Command;

use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterfaceFactory;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger\Collection;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger\CollectionFactory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Recalculates and audits customer points balances from the append-only ledger.
 */
class RecalculatePointsCommand extends Command
{
    private const OPTION_CUSTOMER_ID = 'customer-id';
    private const OPTION_DRY_RUN = 'dry-run';

    /**
     * @param CollectionFactory $ledgerCollectionFactory Factory for the ledger entry collection.
     * @param PointsLedgerRepositoryInterface $pointsLedgerRepository Persists correction entries.
     * @param PointsLedgerInterfaceFactory $pointsLedgerFactory Creates new, unsaved ledger entries.
     * @param string|null $name Optional command name override, forwarded to the parent constructor.
     */
    public function __construct(
        private readonly CollectionFactory $ledgerCollectionFactory,
        private readonly PointsLedgerRepositoryInterface $pointsLedgerRepository,
        private readonly PointsLedgerInterfaceFactory $pointsLedgerFactory,
        ?string $name = null
    ) {
        parent::__construct($name);
    }

    /**
     * Declares the command name, description, and CLI options.
     *
     * @return void
     */
    protected function configure(): void
    {
        $this->setName('mironsoft:loyalty:recalculate');
        $this->setDescription('Recalculates and audits customer points balances from the ledger.');
        $this->addOption(
            self::OPTION_CUSTOMER_ID,
            null,
            InputOption::VALUE_OPTIONAL,
            'Limit the recalculation to a single customer ID.'
        );
        $this->addOption(
            self::OPTION_DRY_RUN,
            null,
            InputOption::VALUE_NONE,
            'Only report discrepancies without writing adjustment entries.'
        );
        parent::configure();
    }

    /**
     * Walks the ledger chronologically per customer, compares the running sum against
     * the stored balance_after, and writes an "adjust" entry on drift unless dry-run.
     *
     * @param InputInterface $input CLI input, provides the command options.
     * @param OutputInterface $output CLI output, used to report progress and drift.
     * @return int
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $customerIdOption = $input->getOption(self::OPTION_CUSTOMER_ID);
        $dryRun = (bool) $input->getOption(self::OPTION_DRY_RUN);

        $collection = $this->ledgerCollectionFactory->create();
        if ($customerIdOption !== null) {
            $collection->addCustomerFilter((int) $customerIdOption);
        }
        $collection->setOrder('customer_id', Collection::SORT_ORDER_ASC);
        $collection->addOrder('created_at', Collection::SORT_ORDER_ASC);

        $runningBalance = [];
        $correctedEntries = 0;

        /** @var PointsLedgerInterface $entry */
        foreach ($collection as $entry) {
            $customerId = $entry->getCustomerId();
            $expectedBalance = ($runningBalance[$customerId] ?? 0) + $entry->getPoints();
            $runningBalance[$customerId] = $expectedBalance;

            if ($expectedBalance === $entry->getBalanceAfter()) {
                continue;
            }

            $output->writeln(sprintf(
                'Drift detected for customer #%d: ledger says %d, recalculated %d.',
                $customerId,
                $entry->getBalanceAfter(),
                $expectedBalance
            ));

            if ($dryRun) {
                continue;
            }

            $adjustment = $this->pointsLedgerFactory->create();
            $adjustment->setCustomerId($customerId);
            $adjustment->setType(PointsLedgerInterface::TYPE_ADJUST);
            $adjustment->setPoints($expectedBalance - $entry->getBalanceAfter());
            $adjustment->setBalanceAfter($expectedBalance);
            $this->pointsLedgerRepository->save($adjustment);

            $runningBalance[$customerId] = $expectedBalance;
            $correctedEntries++;
        }

        $output->writeln(sprintf('Recalculation finished, %d correction(s) written.', $correctedEntries));

        return Command::SUCCESS;
    }
}

Registering in di.xml

Console commands are registered via the array argument commands of Magento\Framework\Console\CommandListInterface - here's the complete di.xml, extended in chapter 9 compared to chapter 6.

app/code/Mironsoft/Loyalty/etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Mironsoft\Loyalty\Api\Data\PointsLedgerInterface"
                type="Mironsoft\Loyalty\Model\PointsLedger"/>
    <preference for="Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface"
                type="Mironsoft\Loyalty\Model\PointsLedgerRepository"/>
    <type name="Magento\Framework\Console\CommandListInterface">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="mironsoft_loyalty_recalculate" xsi:type="object">
                    Mironsoft\Loyalty\Console\Command\RecalculatePointsCommand
                </item>
            </argument>
        </arguments>
    </type>
</config>

Using the command through the bin/ wrapper

Like every other Magento console command in this project, it always runs through the bin/magento wrapper from the Mark Shust setup, never directly via php bin/magento.

# Dry-run first, without writing anything
bin/magento mironsoft:loyalty:recalculate --dry-run

# Check a single customer only
bin/magento mironsoft:loyalty:recalculate --customer-id=42 --dry-run

# Actually apply corrections
bin/magento mironsoft:loyalty:recalculate

Achtung: On a production environment, this command should only ever run with --dry-run followed by a manual review of the reported discrepancies, before running it without that flag - an automatic correction that itself relies on a flawed assumption can make a discrepancy worse instead of fixing it.

Tipp: This exact kind of pure, well-bounded logic - running sum, comparison, correction row - is what makes PointsCalculator (chapter 5) and the repository (chapter 6) ideal candidates for the unit and integration tests in block 11. The console command itself is deliberately not tested directly there, only the building blocks it's assembled from.

Block 1 complete

Nine chapters, one complete data foundation: table, model/resource model/collection, a pure business logic service, a repository, a configuration page, a cache type, and an audit command. The complete directory structure after this chapter matches the target structure from chapter 2 exactly - nothing had to be restructured afterward.

Mironsoft\Loyalty, complete after block 1

app/code/Mironsoft/Loyalty/
├── registration.php
├── composer.json
├── etc/
│   ├── module.xml
│   ├── di.xml
│   ├── acl.xml
│   ├── cache.xml
│   ├── config.xml
│   ├── db_schema.xml
│   └── adminhtml/
│       └── system.xml
├── Api/
│   ├── PointsLedgerRepositoryInterface.php
│   └── Data/
│       └── PointsLedgerInterface.php
├── Model/
│   ├── PointsLedger.php
│   ├── PointsLedgerRepository.php
│   ├── Cache/
│   │   └── Type/
│   │       └── LoyaltyCatalog.php
│   ├── Config/
│   │   └── LoyaltyConfig.php
│   ├── ResourceModel/
│   │   ├── PointsLedger.php
│   │   └── PointsLedger/
│   │       └── Collection.php
│   └── Service/
│       └── PointsCalculator.php
└── Console/
    └── Command/
        └── RecalculatePointsCommand.php

Block 2 picks up exactly here and builds this series' first EAV entity: the rewards themselves, which customers redeem their points against in chapters 10 through 18.