Building Store Credit / Customer Balance in Magento 2 Yourself
AI generated
M2
di.xml
Magento 2 · Store Credit · Customer Balance · Custom Module
Store Credit and Customer Balance in Magento 2
a custom module for a balance account instead of a voucher code

Magento Open Source has no native store credit feature: the feature for an account-bound customer balance exists only in Adobe Commerce. Anyone who still wants to offer store credit, to refund returns as balance instead of a chargeback, or to build a loyalty program with an ongoing customer balance account, builds a custom module: with declarative schema, Service Contracts, an immutable ledger and a dedicated checkout total calculation.

18 min read db_schema.xml · Service Contracts · Observer · Quote Total Magento 2.4.8-p4 · PHP 8.4 · Hyva

1. Store credit as a concept: a balance account instead of a code

A store credit differs fundamentally from a gift card code. A gift card is a redeemable code with its own, often anonymous balance value that exists independently of a specific customer account and can in principle be passed on. Store credit, often called customer balance, is instead an ongoing account firmly tied to the customer entity: no code, no transfer, but a balance that is automatically available at every login and in every checkout and changes over time.

The typical use cases for a customer balance account lie where a refund does not necessarily have to flow back to the original payment method. On a return, the amount can be credited as store credit instead of a chargeback to the credit card, which saves payment provider fees and motivates the customer toward the next purchase. A loyalty program can regularly credit small amounts as customer balance without needing to generate and distribute voucher codes for it. Goodwill cases too, where support wants to credit a customer an amount without a concrete return, benefit from a central balance account instead of many individual voucher codes.

Magento Open Source ships with no native store credit feature: the module Magento_CustomerBalance, which represents this concept in the Commerce edition under the name "Store Credit" or "Customer Balance", is reserved exclusively for Adobe Commerce. For Open Source projects, the only path is therefore a standalone module that cleanly rebuilds the core idea, a persistent balance tied to the customer with full traceability, in its own namespace. Exactly this build, from the database through Service Contracts to the Hyva frontend, is the subject of this article.

2. Architecture of a custom store credit module

A clean store credit module starts with its own namespace under the project convention Mironsoft. The module name Mironsoft_StoreCredit lives under app/code/Mironsoft/StoreCredit and requires a registration.php and an etc/module.xml with dependencies on the core modules that make the balance account meaningful in the first place: Magento_Customer for the customer entity, Magento_Sales for the link to orders and credit memos, and Magento_Quote for the later checkout integration.

The module.xml defines this order via sequence entries, so the setup system creates the customer data tables before the custom customer balance schema. The folder structure follows the usual Magento 2 pattern: Api and Api/Data for the Service Contract interfaces, Model and Model/ResourceModel for the implementation, Observer for the event listeners, Block/ViewModel for the Hyva integration, and Controller/Adminhtml for manual adjustment in the backend.

Important for this project's dual-vendor convention: namespace, module name and all configuration paths are consistently maintained under Mironsoft, while a parallel copy exists under Abrams with identical structure and only the namespace swapped. Both variants share the same dependency on Mironsoft_Core or Abrams_Core respectively, so shared helper classes do not need to be duplicated.

3. Data model: balance table and ledger principle

The data model of a custom store credit module consists of two tables with clearly separated responsibility. The first table, mironsoft_storecredit_balance, holds exactly one row per customer with the current balance: a 1:1 relationship to customer_entity via a unique foreign-key column. This table answers quickly and without aggregation the question "what is the customer balance right now".

The second table, mironsoft_storecredit_ledger, is the actual core of the data model: an immutable journal of all bookings. Every credit and every debit creates exactly one new row with amount, reason, reference to the triggering entity, the balance after this booking, and, for manual adjustments, the admin user's ID. The rule that makes this ledger principle robust in the first place: the balance in mironsoft_storecredit_balance is never changed via a direct UPDATE, but exclusively within the same database transaction that also writes the associated ledger row. This makes it possible to reconstruct the current balance at any time from the sum of all ledger entries of a customer and check it against discrepancies, which is indispensable for accounting and support.

The indexes on customer_id and reason_code as well as on reference_type and reference_id speed up both the account history in the customer account and the idempotency check, which becomes important in section 5 for automatic crediting on returns. The complete db_schema.xml for both tables looks like this:


<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="mironsoft_storecredit_balance" resource="default" engine="innodb" comment="Store Credit Balance">
        <column xsi:type="int" name="entity_id" unsigned="true" nullable="false" identity="true" comment="Entity ID"/>
        <column xsi:type="int" name="customer_id" unsigned="true" nullable="false" comment="Customer ID"/>
        <column xsi:type="decimal" name="current_balance" scale="4" precision="12" unsigned="false" nullable="false" default="0.0000" comment="Current Store Credit Balance"/>
        <column xsi:type="varchar" name="currency_code" nullable="false" length="3" default="EUR" comment="Currency Code"/>
        <column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false" default="CURRENT_TIMESTAMP" comment="Updated At"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="entity_id"/>
        </constraint>
        <constraint xsi:type="unique" referenceId="MIRONSOFT_STORECREDIT_BALANCE_CUSTOMER_ID">
            <column name="customer_id"/>
        </constraint>
        <constraint xsi:type="foreign" referenceId="MIRONSOFT_STORECREDIT_BALANCE_CUSTOMER_ID_CUSTOMER_ENTITY_ENTITY_ID"
                    table="mironsoft_storecredit_balance" column="customer_id"
                    referenceTable="customer_entity" referenceColumn="entity_id" onDelete="CASCADE"/>
    </table>
    <table name="mironsoft_storecredit_ledger" resource="default" engine="innodb" comment="Store Credit Ledger">
        <column xsi:type="int" name="ledger_id" unsigned="true" nullable="false" identity="true" comment="Ledger ID"/>
        <column xsi:type="int" name="customer_id" unsigned="true" nullable="false" comment="Customer ID"/>
        <column xsi:type="decimal" name="amount_delta" scale="4" precision="12" unsigned="false" nullable="false" comment="Amount Delta, positive equals credit, negative equals debit"/>
        <column xsi:type="decimal" name="balance_after" scale="4" precision="12" unsigned="false" nullable="false" comment="Balance After This Entry"/>
        <column xsi:type="varchar" name="reason_code" nullable="false" length="64" comment="Reason Code, e.g. creditmemo_refund, admin_adjustment"/>
        <column xsi:type="varchar" name="reference_type" nullable="true" length="64" comment="Reference Entity Type"/>
        <column xsi:type="int" name="reference_id" unsigned="true" nullable="true" comment="Reference Entity ID"/>
        <column xsi:type="int" name="admin_user_id" unsigned="true" nullable="true" comment="Admin User ID for manual adjustments"/>
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="ledger_id"/>
        </constraint>
        <constraint xsi:type="foreign" referenceId="MIRONSOFT_STORECREDIT_LEDGER_CUSTOMER_ID_CUSTOMER_ENTITY_ENTITY_ID"
                    table="mironsoft_storecredit_ledger" column="customer_id"
                    referenceTable="customer_entity" referenceColumn="entity_id" onDelete="CASCADE"/>
        <index referenceId="MIRONSOFT_STORECREDIT_LEDGER_CUSTOMER_ID_REASON_CODE" indexType="btree">
            <column name="customer_id"/>
            <column name="reason_code"/>
        </index>
        <index referenceId="MIRONSOFT_STORECREDIT_LEDGER_REFERENCE_TYPE_REFERENCE_ID" indexType="btree">
            <column name="reference_type"/>
            <column name="reference_id"/>
        </index>
    </table>
</schema>

4. Service Contracts: BalanceRepositoryInterface and BalanceManagementInterface

The project convention demands Service Contracts instead of direct model access, and this pays off especially well for a store credit module. Under Api/Data, a BalanceInterface defines the pure getters and setters of the balance entity. Under Api, BalanceRepositoryInterface describes loading and saving by customer ID, while BalanceManagementInterface encapsulates the actual business logic: credit() and debit() as clearly named operations that never manipulate the balance directly but always run through the ledger booking.

The implementation BalanceManagement consistently uses constructor property promotion and encapsulates the transaction logic: load balance, calculate new value, save balance and write ledger row, all within the same database transaction. If any step fails, the entire transaction is rolled back, so an inconsistent state between the balance table and the ledger can never arise. This class is the only place in the entire module allowed to write to the customer balance balance, all other components, observers, admin controller and ViewModel, exclusively call these Service Contracts.


<?php

declare(strict_types=1);

namespace Mironsoft\StoreCredit\Model;

use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Exception\LocalizedException;
use Mironsoft\StoreCredit\Api\BalanceManagementInterface;
use Mironsoft\StoreCredit\Api\BalanceRepositoryInterface;
use Mironsoft\StoreCredit\Model\ResourceModel\Ledger as LedgerResource;

/**
 * Service class for crediting and debiting the Store Credit ledger.
 */
class BalanceManagement implements BalanceManagementInterface
{
    /**
     * @param BalanceRepositoryInterface $balanceRepository Repository for the balance entity
     * @param LedgerFactory $ledgerFactory Factory for ledger entry entities
     * @param LedgerResource $ledgerResource Resource model for persisting ledger entries
     * @param ResourceConnection $resourceConnection Database connection for transaction handling
     */
    public function __construct(
        private readonly BalanceRepositoryInterface $balanceRepository,
        private readonly LedgerFactory $ledgerFactory,
        private readonly LedgerResource $ledgerResource,
        private readonly ResourceConnection $resourceConnection
    ) {
    }

    /**
     * Credits an amount to the customer's Store Credit balance and writes an immutable ledger entry.
     *
     * @param int $customerId Customer entity ID
     * @param float $amount Amount to credit, always positive
     * @param string $reasonCode Reason code, e.g. "creditmemo_refund"
     * @param string|null $referenceType Reference entity type, e.g. "creditmemo"
     * @param int|null $referenceId Reference entity ID
     * @return float New balance after the credit
     * @throws LocalizedException
     */
    public function credit(
        int $customerId,
        float $amount,
        string $reasonCode,
        ?string $referenceType = null,
        ?int $referenceId = null
    ): float {
        if ($amount <= 0.0) {
            throw new LocalizedException(__('Credit amount must be positive.'));
        }

        $connection = $this->resourceConnection->getConnection();
        $connection->beginTransaction();

        try {
            $balance = $this->balanceRepository->getByCustomerId($customerId);
            $newBalance = round($balance->getCurrentBalance() + $amount, 4);
            $balance->setCurrentBalance($newBalance);
            $this->balanceRepository->save($balance);

            $ledgerEntry = $this->ledgerFactory->create();
            $ledgerEntry->setCustomerId($customerId);
            $ledgerEntry->setAmountDelta($amount);
            $ledgerEntry->setBalanceAfter($newBalance);
            $ledgerEntry->setReasonCode($reasonCode);
            $ledgerEntry->setReferenceType($referenceType);
            $ledgerEntry->setReferenceId($referenceId);
            $this->ledgerResource->save($ledgerEntry);

            $connection->commit();
        } catch (\Throwable $exception) {
            $connection->rollBack();
            throw new LocalizedException(__('Store Credit could not be credited.'), $exception);
        }

        return $newBalance;
    }
}

5. Automatic credit on return

The most compelling use case for a custom store credit module is automatic crediting on a return. An observer on the event sales_order_creditmemo_save_after checks whether the refund for the relevant credit memo was chosen as customer balance, and then calls BalanceManagementInterface::credit() with the credit memo amount. The observer itself contains no booking logic, it fully delegates to the Service Contract from section 4.

What matters is idempotency: the event sales_order_creditmemo_save_after can be triggered multiple times when the same credit memo is saved again, for example after a reindex or a manual correction in admin. Without a protective mechanism, the same amount would be credited multiple times. The solution: before every credit, the observer checks via reference_type and reference_id in the ledger whether a booking already exists for this specific credit memo, and aborts without error otherwise. This check makes the entire process repeatable and safe against double booking.


<?php

declare(strict_types=1);

namespace Mironsoft\StoreCredit\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order\Creditmemo;
use Mironsoft\StoreCredit\Api\BalanceManagementInterface;
use Mironsoft\StoreCredit\Api\LedgerRepositoryInterface;
use Psr\Log\LoggerInterface;

/**
 * Credits Store Credit automatically when a credit memo is created with refund-to-storecredit selected.
 */
class CreditMemoStoreCreditObserver implements ObserverInterface
{
    private const REASON_CODE = 'creditmemo_refund';
    private const REFERENCE_TYPE = 'creditmemo';

    /**
     * @param BalanceManagementInterface $balanceManagement Service for crediting the ledger
     * @param LedgerRepositoryInterface $ledgerRepository Repository for checking existing ledger entries
     * @param LoggerInterface $logger Logger for failed credit attempts
     */
    public function __construct(
        private readonly BalanceManagementInterface $balanceManagement,
        private readonly LedgerRepositoryInterface $ledgerRepository,
        private readonly LoggerInterface $logger
    ) {
    }

    /**
     * Executes the observer on sales_order_creditmemo_save_after.
     *
     * @param Observer $observer Event observer instance
     * @return void
     */
    public function execute(Observer $observer): void
    {
        /** @var Creditmemo $creditmemo */
        $creditmemo = $observer->getEvent()->getData('creditmemo');

        if (!$creditmemo->getData('refund_to_storecredit')) {
            return;
        }

        // Idempotency guard: skip if this creditmemo already produced a ledger entry.
        // Prevents double crediting on reindex, resave or repeated event dispatch.
        if ($this->ledgerRepository->existsByReference(self::REFERENCE_TYPE, (int) $creditmemo->getEntityId())) {
            return;
        }

        $customerId = (int) $creditmemo->getOrder()->getCustomerId();
        if ($customerId === 0) {
            return;
        }

        try {
            $this->balanceManagement->credit(
                $customerId,
                (float) $creditmemo->getGrandTotal(),
                self::REASON_CODE,
                self::REFERENCE_TYPE,
                (int) $creditmemo->getEntityId()
            );
        } catch (\Throwable $exception) {
            $this->logger->error('Store Credit credit failed for creditmemo ' . $creditmemo->getEntityId(), ['exception' => $exception]);
        }
    }
}

6. Checkout integration: a custom quote address total collector

For the customer balance to actually be offset at checkout, the mere existence of the balance is not enough: Magento must incorporate the amount into the quote's grand total calculation. The designated extension point for this is a custom total collector, a class that inherits from Magento\Quote\Model\Quote\Address\Total\AbstractTotal and implements the methods collect() and fetch(). In collect(), the available store credit amount is compared with the current grand total and deducted at most up to the amount of the grand total, so the order never shows a negative total.

This total collector is registered not in di.xml but via the dedicated totals.xml, which Magento provides for exactly this purpose. The sort_order decides at which point in the totals calculation chain, after shipping and tax but before final rounding, the store credit deduction takes effect. This clean separation via a dedicated XML schema instead of a generic plugin solution matches Magento's intended path for quote totals and stays stable across core updates.


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Quote:etc/totals.xsd">
    <total_collectors sales_channel="website">
        <default>
            <storecredit instance="Mironsoft\StoreCredit\Model\Total\Quote\StoreCredit" sort_order="100"/>
        </default>
    </total_collectors>
    <total_collectors sales_channel="quote">
        <default>
            <storecredit instance="Mironsoft\StoreCredit\Model\Total\Quote\StoreCredit" sort_order="100"/>
        </default>
    </total_collectors>
</config>

7. Admin UI: manually adjusting balance with an audit log

Besides automatic crediting on returns, every store credit module needs a manual adjustment option in the backend, for example for goodwill cases or corrections by support. A custom UI Component grid under Customer > Store Credit shows the current balance per customer as well as the complete ledger history with date, amount, reason and, where available, the responsible admin user. A form allows adding a new booking with a mandatory reason field.

Per project rule, every new functionality needs its own ACL entry in etc/acl.xml, here for example Mironsoft_StoreCredit::manage, checked in the Adminhtml controller before every adjustment. Every manual change runs, as described in section 4, exclusively through BalanceManagementInterface and creates a ledger row with the ID of the executing admin user. This makes every change to the customer balance fully traceable: who, when, why and in what amount.

8. Frontend display in the customer account: ViewModel pattern

In the customer account area of the Hyva theme, the customer should be able to view their current store credit balance and the history of their bookings. Per project convention, no block is used for this, but a ViewModel following the ArgumentInterface pattern, injected via layout XML into the template account/storecredit.phtml. The ViewModel encapsulates access to the customer session and delegates to BalanceRepositoryInterface and LedgerRepositoryInterface, without containing business logic itself.

In the template, Hyva iterates over the array from getLedgerHistory() and renders a simple table with date, amount and reason per row, complemented by Tailwind classes for positive and negative amounts. Since the ViewModel returns no heavyweight objects, only primitive values and flat arrays, the component remains unproblematic even with Hyva CSP mode enabled and needs no additional inline scripts.


<?php

declare(strict_types=1);

namespace Mironsoft\StoreCredit\ViewModel;

use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Mironsoft\StoreCredit\Api\BalanceRepositoryInterface;
use Mironsoft\StoreCredit\Api\LedgerRepositoryInterface;

/**
 * Exposes the current Store Credit balance and ledger history to Hyva templates.
 */
class CustomerBalance implements ArgumentInterface
{
    /**
     * @param CustomerSession $customerSession Current customer session
     * @param BalanceRepositoryInterface $balanceRepository Repository for the balance entity
     * @param LedgerRepositoryInterface $ledgerRepository Repository for ledger history entries
     */
    public function __construct(
        private readonly CustomerSession $customerSession,
        private readonly BalanceRepositoryInterface $balanceRepository,
        private readonly LedgerRepositoryInterface $ledgerRepository
    ) {
    }

    /**
     * Returns the current Store Credit balance for the logged in customer.
     *
     * @return float
     */
    public function getCurrentBalance(): float
    {
        $customerId = (int) $this->customerSession->getCustomerId();
        if ($customerId === 0) {
            return 0.0;
        }

        return $this->balanceRepository->getByCustomerId($customerId)->getCurrentBalance();
    }

    /**
     * Returns the ledger history for the logged in customer, newest entries first.
     *
     * @param int $limit Maximum number of entries to return
     * @return array<int, array<string, mixed>>
     */
    public function getLedgerHistory(int $limit = 20): array
    {
        $customerId = (int) $this->customerSession->getCustomerId();
        if ($customerId === 0) {
            return [];
        }

        return $this->ledgerRepository->getRecentByCustomerId($customerId, $limit);
    }
}

9. Store credit compared: custom module vs. gift cards vs. reward points

A custom store credit module is not the only way to give customers monetary value back. Gift cards with codes and reward point systems solve similar business goals with different technical and accounting prerequisites. The following table compares the three approaches along the dimensions that decide the choice in practice.

Dimension Store Credit (Custom Module) Gift Card Codes Reward Points
Tied to customer account fixed, no code, no transfer no binding, code freely transferable fixed, but usually tied to a point value
Implementation effort high: schema, Service Contracts, total, admin, frontend medium: code generation, redemption logic high: point rules, conversion logic, expiry dates
Accounting logic ledger principle, fully auditable code status (active/redeemed), less granular point balance, often without monetary reference
Use case return refunds, goodwill, ongoing balance gifts, marketing campaigns, B2B vouchers customer retention, repeat-purchase incentive

The three approaches are not mutually exclusive. In practice, many shops combine a store credit account for returns and goodwill with gift card codes for marketing campaigns, while reward points sit on top as a separate incentive system. What matters is that each system has its own, clearly delimited data model, and no attempt is made to squeeze all three concepts into one shared table, since the accounting semantics differ in details that quickly lead to inconsistencies.

10. Summary

A custom store credit module for Magento 2 Open Source is not a trivial feature flag but a complete custom build: a balance table and an immutable ledger as the data model, Service Contracts that route every booking exclusively through a central management class, an observer for automatic crediting on returns with a clean idempotency check, a custom quote total collector for checkout offsetting, plus an ACL-secured admin grid and a Hyva ViewModel for the customer account display.

The common thread through all sections is the ledger principle: never write the balance directly, always produce a traceable booking. Anyone who consistently upholds this principle gets a customer balance system that can be defended both against double bookings and against accounting questions arising later, without needing an Adobe Commerce license for it.

Store Credit and Customer Balance in Magento 2: The Essentials at a Glance

Data model & ledger

Balance table plus immutable ledger journal. Balance is never written directly, always derived from a booking.

Service Contracts

BalanceRepositoryInterface and BalanceManagementInterface bundle every credit and debit in one transaction.

Checkout integration

Custom total collector via totals.xml, offsets the balance safely against the grand total, never into negative.

Admin & frontend

ACL-secured admin grid for manual adjustments, Hyva ViewModel for the customer account display.

11. FAQ: Store Credit in Magento 2

1Difference between store credit and a gift card?
Store credit is tied to the customer account and available directly at checkout without a code. A gift card is an independent, transferable code.
2Does Magento Open Source have store credit natively?
No. Magento_CustomerBalance exists only in Adobe Commerce. In Open Source, a custom module is the only path.
3How does the ledger principle prevent booking errors?
Every booking creates an immutable ledger row with the balance after the booking, from which the balance can be reconstructed at any time.
4Why never change the balance via direct UPDATE?
Without an accompanying ledger row, the change is untraceable. Balance update and ledger booking must happen in one transaction.
5How is store credit offset at checkout?
A custom total collector via totals.xml deducts the amount from the grand total, at most up to its amount.
6How is a double credit prevented?
Checking via reference_type and reference_id in the ledger before every booking prevents double bookings on reindex or resave.
7Does the module need its own ACL permission?
Yes, a dedicated ACL entry for manual adjustment in the admin grid is mandatory per project convention.
8How do you display balance in the Hyva frontend?
Via a ViewModel following the ArgumentInterface pattern, providing balance and ledger history as simple values.
9Can store credit be combined with voucher codes?
Yes, both systems can run in parallel as long as the data models stay cleanly separated.
10What happens to the balance when the account is deleted?
A foreign-key cascade on customer_entity automatically removes balance and ledger, alternatively an export before deletion is advisable.

Mironsoft

Magento 2 custom development, Service Contracts and Hyva frontends

Want store credit built for your Magento 2 shop?

We build your custom store credit and customer balance module, from db_schema.xml through Service Contracts and checkout total to the Hyva frontend in the customer account, cleanly following Magento 2 conventions.

Module design

Data model, ledger design and Service Contracts for your store credit concept

Checkout integration

Custom quote total collector, cleanly registered via totals.xml

Admin & Hyva frontend

ACL-secured admin grid and ViewModel-based customer account display