How to Build a Custom Customer Credit System for Returns and Store Credit in Magento 2

Returns are part of e-commerce life — and handling them cleanly can turn a costly, confusing process into a retention opportunity. One common pattern is store credit: au lieu de refunding money, give the client an internal credit they can spend later. In Magento 2 you can implement this pattern yourself with a custom "client_credit" entity, hooks into refunds/avoirs (or RMA events), an admin UI to manage balances, and frontend integration so clients can see and use their credit at paiement.

What you’ll get from this post

  • A clear technical architecture to add a client_credit entity and balance tracking
  • Concrete code exemples (declarative db schema, models, observateurs, total collector, admin UI snippets)
  • How to automatically grant credit on approved returns and calculate amounts
  • Admin tools: balance management and transaction history
  • Frontend and paiement integration so clients can apply credit
  • Notes about integnote with other extensions like Force Product Stock Status

High-level architecture

Keep the architecture simple and modular. At its core we’ll add two pieces of persistent data and a few integrations:

  • client_credit (transaction log): each credit or debit event (refunds, manual adjustments, usage) is recorded as a transaction.
  • client_credit_balance (optional): cached balance per client for fast reads. Alternatively you can compute balance by summing transactions, but for performance we’ll include the table and update it transactionally.
  • Observers / plugins: listen for sales_creditmemo_save_after (and RMA events if you use an RMA extension) to create a credit transaction automatically on approved returns.
  • Admin UI: grid and edit forms to view transactions and manage balances.
  • Frontend: a block in compte client and a paiement total collector (or méthode de paiement) to apply credit.

Keep everything in a single Magento module vendor/magefine_clientcredit (use your vendor name). Use declarative schema (db_schema.xml) for tables and use contrat de services where possible so other modules can interact with balances via well-defined interfaces.

Database schema (declarative)

Create two tables: client_credit (transaction log) and client_credit_balance (balance cache). Here’s an exemple db_schema.xml snippet placed in etc/db_schema.xml of your module:

<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="customer_credit" resource="default" engine="innodb" comment="Customer Credit Transactions">
        <column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true"/>
        <column xsi:type="int" name="customer_id" nullable="false" unsigned="true"/>
        <column xsi:type="decimal" name="amount" scale="2" precision="10" nullable="false" default="0.00"/>
        <column xsi:type="decimal" name="balance_after" scale="2" precision="10" nullable="true"/>
        <column xsi:type="varchar" name="type" length="50" nullable="false" default="credit" comment="credit, debit, adjustment"/>
        <column xsi:type="int" name="order_id" nullable="true" unsigned="true"/>
        <column xsi:type="int" name="credit_memo_id" nullable="true" unsigned="true"/>
        <column xsi:type="text" name="comment" nullable="true"/>
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="entity_id"/>
        </constraint>
        <index referenceId="CUSTOMER_ID_IDX" >
            <column name="customer_id"/>
        </index>
    </table>

    <table name="customer_credit_balance" resource="default" engine="innodb" comment="Customer Credit Balances">
        <column xsi:type="int" name="customer_id" unsigned="true" nullable="false"/>
        <column xsi:type="decimal" name="balance" scale="2" precision="10" nullable="false" default="0.00"/>
        <column xsi:type="timestamp" name="updated_at" nullable="false" default="CURRENT_TIMESTAMP" on_update="true"/>
        <constraint xsi:type="primary" referenceId="PRIMARY">
            <column name="customer_id"/>
        </constraint>
    </table>
</schema>

Notes:

  • Store decimals with precision and scale to avoid rounding problèmes.
  • We store balance_after on each transaction for auditability and easier reconciliations.
  • Use client_id as the PK in the balance table so updates are simple UPSERTs.

Service contracts and repository

Create simple contrat de services (interfaces) so other modules can grant credit or retrieve balances without coupling to models. Example interface located in Api/CustomerCreditManagementInterface.php:

namespace Vendor\CustomerCredit\Api;

interface CustomerCreditManagementInterface
{
    /**
     * Add a credit transaction for a customer
     * @param int $customerId
     * @param float $amount
     * @param string $type
     * @param array $meta
     * @return bool
     */
    public function addTransaction($customerId, $amount, $type = 'credit', $meta = []);

    /**
     * Get current balance
     * @param int $customerId
     * @return float
     */
    public function getBalance($customerId);
}

Then implement the interface in Model/CustomerCreditManagement.php. Assurez-vous to wrap DB updates in a transaction so creating the transaction and updating the balance table are atomic.

use Magento\Framework\DB\Adapter\AdapterInterface as DbAdapter;
use Magento\Framework\Exception\LocalizedException;

public function addTransaction($customerId, $amount, $type = 'credit', $meta = [])
{
    $connection = $this->resourceConnection->getConnection();
    $connection->beginTransaction();
    try {
        $currentBalance = $this->getBalance($customerId);
        $newBalance = bcadd((string)$currentBalance, (string)$amount, 2);

        // insert into customer_credit
        $this->resourceConnection->getConnection()->insert('customer_credit', [
            'customer_id' => $customerId,
            'amount' => $amount,
            'balance_after' => $newBalance,
            'type' => $type,
            'comment' => isset($meta['comment']) ? $meta['comment'] : null,
            'order_id' => isset($meta['order_id']) ? $meta['order_id'] : null,
            'credit_memo_id' => isset($meta['credit_memo_id']) ? $meta['credit_memo_id'] : null,
        ]);

        // upsert balance
        $connection->insertOnDuplicate('customer_credit_balance', [
            'customer_id' => $customerId,
            'balance' => $newBalance,
            'updated_at' => new \Zend\Db\Sql\Expression('NOW()')
        ], ['balance', 'updated_at']);

        $connection->commit();
        return true;
    } catch (\Exception $e) {
        $connection->rollBack();
        throw new LocalizedException(__($e->getMessage()));
    }
}

This implémentation uses insertOnDuplicate (available via Magento DB adapter) to upsert the balance ligne.

Triggering credit on approved returns

Magento uses avoirs for refunds. The simplest integration is to listen to the sales_commande_creditmemo_save_after event (or use a plugin on CreditmemoRepository save). If your store uses an RMA extension, either listen to its approval event or keep using avoir events (many RMAs create a avoir when approved).

Create an observateur at etc/events.xml:

<event name="sales_order_creditmemo_save_after">
    <observer name="vendor_customercredit_creditmemo_observer" instance="Vendor\CustomerCredit\Observer\CreditmemoSaveAfter" />
</event>

Observer exemple (Observer/CreditmemoSaveAfter.php):

namespace Vendor\CustomerCredit\Observer;

use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;

class CreditmemoSaveAfter implements ObserverInterface
{
    protected $creditManagement;
    protected $orderRepository;

    public function __construct(
        \Vendor\CustomerCredit\Api\CustomerCreditManagementInterface $creditManagement,
        \Magento\Sales\Api\OrderRepositoryInterface $orderRepository
    ) {
        $this->creditManagement = $creditManagement;
        $this->orderRepository = $orderRepository;
    }

    public function execute(Observer $observer)
    {
        /** @var \Magento\Sales\Model\Order\Creditmemo $creditmemo */
        $creditmemo = $observer->getEvent()->getCreditmemo();
        $order = $creditmemo->getOrder();
        $customerId = $order->getCustomerId();
        if (!$customerId) {
            return;
        }

        // Calculate credit to grant. This example grants the grand total of the credit memo.
        $amount = (float)$creditmemo->getGrandTotal();

        if ($amount <= 0) {
            return;
        }

        // Option: check if merchant chose refund type = 'store_credit' using custom field on creditmemo
        $meta = [
            'order_id' => $order->getEntityId(),
            'credit_memo_id' => $creditmemo->getEntityId(),
            'comment' => 'Refund converted to store credit via creditmemo'
        ];

        $this->creditManagement->addTransaction($customerId, $amount, 'credit', $meta);
    }
}

Notes on calculation and entreprise rules:

  • You might not always want to credit the full avoir. For partial returns, compute item-level amounts, shipping restock fees, taxes. Use the creditmemo propriétés (getSubtotal, getShippingAmount, getAdjustmentPositive/Negative, tax amounts) to compute precisely.
  • If your store offers cash refunds sometimes, record the refund méthode (store credit vs. cash) and only create credit transactions when store credit is selected.
  • Use a flag or admin UI during avoir creation to choose store credit au lieu de cash. That may involve extending the admin avoir UI and saving a custom champ.

Admin interface: transaction history and manual balance adjustment

Admins must see a transaction history and be able to adjust balances (positive or negative) with a reason. Build an grille d'administration for client_credit and a form to add adjustments.

Key fichiers and concepts:

  • etc/adminhtml/menu.xml and acl.xml - register menu entries and ACL resources
  • Controller/Adminhtml/Transaction/Index.php - grid contrôleur
  • ui_composant/client_credit_listing.xml - define the grid colonnes and data provider
  • Form UI composant to add adjustments

Example of a simple adjustment contrôleur (Controller/Adminhtml/Transaction/Adjust.php):

namespace Vendor\CustomerCredit\Controller\Adminhtml\Transaction;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;

class Adjust extends Action
{
    protected $creditManagement;

    public function __construct(Context $context, \Vendor\CustomerCredit\Api\CustomerCreditManagementInterface $creditManagement)
    {
        parent::__construct($context);
        $this->creditManagement = $creditManagement;
    }

    public function execute()
    {
        $data = $this->getRequest()->getPostValue();
        $customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : 0;
        $amount = isset($data['amount']) ? (float)$data['amount'] : 0;
        $type = $amount <= 0 ? 'debit' : 'credit';

        if (!$customerId || $amount == 0) {
            $this->messageManager->addErrorMessage(__('Invalid input'));
            return $this->_redirect('*/*/');
        }

        $this->creditManagement->addTransaction($customerId, $amount, $type, ['comment' => $data['comment'] ?? 'Admin adjustment']);
        $this->messageManager->addSuccessMessage(__('Balance updated'));
        return $this->_redirect('*/*/');
    }
}

Assurez-vous to secure the contrôleur with ACL and to add proper validation and logging. Record who made the manual adjustment in the comment meta so you have an audit trail.

Frontend: displaying the balance to the client

Customers should see their balance in the account area and be able to apply it at paiement. Add a small block to the compte client menu (or tableau de bord) to show current balance and a link to transaction history.

Block exemple (Block/Account/Credit.php):

namespace Vendor\CustomerCredit\Block\Account;

use Magento\Customer\Block\Account\Dashboard;

class Credit extends \Magento\Framework\View\Element\Template
{
    protected $customerSession;
    protected $creditManagement;

    public function __construct(
        \Magento\Framework\View\Element\Template\Context $context,
        \Magento\Customer\Model\Session $customerSession,
        \Vendor\CustomerCredit\Api\CustomerCreditManagementInterface $creditManagement,
        array $data = []
    ) {
        parent::__construct($context, $data);
        $this->customerSession = $customerSession;
        $this->creditManagement = $creditManagement;
    }

    public function getBalance()
    {
        $customerId = $this->customerSession->getCustomerId();
        if (!$customerId) {
            return 0.00;
        }
        return $this->creditManagement->getBalance($customerId);
    }
}

Template (view/frontend/templates/account/credit.phtml):

<?php $balance = $block->getBalance(); ?>
<div class="customer-credit-balance">
    <p><strong>Store Credit:</strong> <span class="price"><?= $block->escapeHtml($block->formatPrice($balance)) ?></span></p>
    <a href="<?= $block->getUrl('customer/credit/history') ?>">View credit history</a>
</div>

Expose a route to show the transaction history grid on the frontend (use a standard list and paging). Vous pouvez reuse the client_credit table data for this.

Apply credit at paiement

Il y a two common ways to let clients use credit at paiement:

  1. Implement a quote total collector that applies a discount equal to the requested store credit amount (and writes quote data about applied_credit). C'est unobtrusive and affects totals directly.
  2. Implement a méthode de paiement called "Store Credit" which acts as an offline méthode and sets commande payment as paid by credit. C'est heavier and requires paiement UI integration.

I recommend the quote total collector approche for predictable totals and simple integration. The collector will reduce quote grand_total and create a payment-related entry so that when the commande is placed you can persist usage as a transaction (debit) to reduce the client's balance.

Example total collector (etc/di.xml registration and Model/Quote/Total/CustomerCredit.php):

// di.xml
<type name="Magento\Quote\Model\Quote\Validator" />
<type name="Magento\Quote\Model\Quote\Address\Total\Collector" />

// registration is normally via <type name="Magento\Quote\Model\Quote\Address\Total"> entries or using sales.xml configuration in 2.4
namespace Vendor\CustomerCredit\Model\Quote\Total;

use Magento\Quote\Model\Quote\Address\Total\AbstractTotal;

class CustomerCredit extends AbstractTotal
{
    protected $customerSession;
    protected $creditManagement;

    public function __construct(
        \Magento\Customer\Model\Session $customerSession,
        \Vendor\CustomerCredit\Api\CustomerCreditManagementInterface $creditManagement
    ) {
        $this->customerSession = $customerSession;
        $this->creditManagement = $creditManagement;
        $this->setCode('customer_credit');
    }

    public function collect(\Magento\Quote\Model\Quote $quote, \Magento\Quote\Model\Quote\Address\Total $total)
    {
        parent::collect($quote, $total);

        // only for frontend, if customer requested to apply credit
        $applyAmount = (float)$quote->getData('applied_customer_credit_amount') ?: 0.00;
        if ($applyAmount <= 0) {
            return $this;
        }

        $customerId = $this->customerSession->getCustomerId();
        if (!$customerId) {
            return $this;
        }

        $balance = $this->creditManagement->getBalance($customerId);
        $maxAllowed = min($balance, $total->getSubtotalInclTax());
        $apply = min($applyAmount, $maxAllowed);

        if ($apply > 0) {
            // subtract from grand total
            $total->setTotalAmount('customer_credit', -$apply);
            $total->setBaseTotalAmount('customer_credit', -$apply);
            $total->setGrandTotal($total->getGrandTotal() - $apply);
            $total->setBaseGrandTotal($total->getBaseGrandTotal() - $apply);

            // store applied amount on quote for later reference
            $quote->setData('applied_customer_credit_amount', $apply);
        }

        return $this;
    }

    public function fetch(\Magento\Quote\Model\Quote $quote, \Magento\Quote\Model\Quote\Address\Total $total)
    {
        $apply = (float)$quote->getData('applied_customer_credit_amount') ?: 0.00;
        if ($apply > 0) {
            return [
                'code' => $this->getCode(),
                'title' => __('Store Credit'),
                'value' => -$apply
            ];
        }
        return [];
    }
}

Important étapes after commande placement:

  • When an commande is placed, if quote had applied_client_credit_amount > 0, create a transaction with type 'debit' and amount = applied_credit to reduce the balance. Use the same contrat de service to create the transaction, making sure it’s atomic and saves commande_id on the transaction.
  • Handle commande cancellation and refunds: if the commande is refunded fully to store credit then create a new credit transaction; if an commande is canceled before invoicing, restore credit by creating a credit transaction or reversing the debit.

Edge cases and important details

Some things to design carefully:

  • Currency: Si vous support mulconseille currencies, store amount colonnes in base currency and provide conversion when applying store credit. Alternatively, store two colonnes: base_amount and display_amount.
  • Rounding: Use bcmath fonctions (bcadd, bcsub) when openote on decimal chaînes to avoid floating point problèmes.
  • Concurrency: Use DB transactions and INSERT ... ON DUPLICATE KEY UPDATE or SELECT FOR UPDATE where needed to avoid race conditions when two processes update the balance simultaneously.
  • Refund méthodes: Assurez-vous admins can choose refund méthode. Consider adding a checkbox to the avoir admin UI to mark the refund as "Store Credit".
  • Events: Expose events like vendor_clientcredit_transaction_added so other modules can react (for exemple, to notify the client via e-mail).

Integration with Force Product Stock Status and other extensions

Si vous use extensions that manage product stock visibility (like Force Product Stock Status), think about the return flow and inventaire updates. When a return is approved you might restock items and that can affect the stock status. Two integration conseils:

  1. Après processing a avoir that includes returned items, trigger an index or event that Force Product Stock Status can listen to so product visibility is recalculated.
  2. If Force Product Stock Status exposes a service or API for updating product visibility, call it from your observateur after restock to keep the ecosystem consistent.

Example (in the avoir observateur) once restock is confirmed:

// pseudo-code
if (moduleIsEnabled('Vendor_ForceStockStatus')) {
    // either dispatch custom event
    $this->eventManager->dispatch('vendor_force_stockstatus_recalculate', ['product_ids' => $ids]);

    // or call the extension service if available
    $this->forceStockStatusService->recalculateForProducts($ids);
}

Always keep integrations optional: test the extension exists before calling it to avoid fatal erreurs.

Notifications and e-mails

Customers devrait être notified when you credit their account. Use Magento’s e-mail templates and events to send a notification when a transaction is created with type 'credit'. Expose a modular e-mail template and translate chaînes for multi-store setups.

Example: discorrectif vendor_clientcredit_transaction_added with transaction data and have an observateur that sends an e-mail using \Magento\Framework\Mail\Template\TransportBuilder.

Testing and QA

Test manually and automatically:

  • Unit tests for your contrat de service implémentation (transactions, rounding, upsert logic).
  • Integration tests covering avoir -> transaction creation and paiement -> debit transaction creation.
  • Functional tests for admin UI (balance adjustment), frontend display, and paiement integration.
  • Performance test: if you expect many transactions, ensure your balance lookups are indexed and the balance table is used for reads rather than summing a huge transaction table each time.

Extra fonctionnalités you may add later

  • Expiration dates for store credit (add expires_at colonne and a cron to mark expired credit and optionally e-mail client)
  • Gift card-style creation and redemption codes
  • Partial application rules (allow only a percentage of commande to be paid with credit, or exclude shipping)
  • Reports in the admin for total outstanding store credit and aging analysis
  • points d'accès API to allow headless/mobile apps to display balance and history

Sample flow: full exemple from return to credit used in paiement

  1. Customer returns an item -> commerçant approves the return and creates a avoir in admin. Merchant checks "Refund as store credit" checkbox (we extended avoir admin UI to support this).
  2. sales_commande_creditmemo_save_after fires -> our observateur calculates credit amount and calls CustomerCreditManagement::addTransaction(clientId, amount, 'credit', meta).
  3. addTransaction writes the transaction to client_credit and updates client_credit_balance within a DB transaction.
  4. Observer then discorrectifs vendor_clientcredit_transaction_added -> mailer sends e-mail to client informing them of new store credit.
  5. Customer logs in and sees the balance in the account tableau de bord. They add items to cart, go to paiement, and choose to apply store credit (we added a small checkbox on the payment étape). The quote now has applied_client_credit_amount set.
  6. Quote total collector runs and subtracts credit from grand total. The displayed totals show a "Store Credit" line with the discount.
  7. Customer places the commande. Order placement code checks quote->getAppliedCustomerCreditAmount(), and if > 0 calls addTransaction(clientId, -appliedAmount, 'debit', ['commande_id' => $commande->getId()]) to reduce balance.
  8. If the commande is canceled and the commerçant chooses to return the credit, create a credit transaction to restore the balance.

Security, permissions and auditing

Some operational notes:

  • Restrict admin actions using ACL so only permitted admin roles can manually change balances.
  • Log every manual adjustment and consider adding an admin_utilisateur_id colonne to client_credit to record who made the change; include that in the grille d'administration for traceability.
  • Validate all inputs server-side. Never trust client-provided applied_client_credit_amount; recompute the allowed amount on the server side as demonstrated in the collector.

Wrap-up and conseils

Building a robust client credit system in Magento 2 is a great way to improve client satisfaction and reduce chargeback friction. Keep these practical conseils in mind:

  • Use a transaction log (client_credit) and a cached balance table for performance and auditability.
  • Trigger credit creation on avoir or RMA approval, and expose a clear option in admin to choose store credit vs. cash.
  • Use DB transactions and bcmath for safe math and concurrent updates.
  • Integrate with paiement using a quote total collector to make totals predictable and easy to display.
  • Integrate optional extensions (like Force Product Stock Status) by discorrectifing events after restock or calling extension services, but always check for module existence first.

Si vous follow the patterns described (declarative schema, contrat de services, observateurs, totals collectors and admin UI) you’ll end up with a maintainable, testable store-credit system that fits well inside the Magento ecosystem and peut être extended later (expiration, gift codes, points d'accès API).

Si vous want, I can provide a minimal downloadable module skeleton for vendor/magefine_clientcredit with all the fichiers wired together (db_schema.xml, registration.php, module.xml, sample observateur, service implémentation, and a tiny grille d'administration config). That skeleton will get you started and you can adapt exact entreprise rules (tax handling, partial refunds, expiration) to your store’s policy.

Want the module skeleton? Say the word and I’ll generate the full dossier fichier list and the essential PHP/XML fichiers ready to paste into your codebase.

Happy coding — and don’t forget to test the credit flows end-to-end (refund to credit, credit to paiement, cancellations) before switching this live. C'est a small fonctionnalité that pays off big on CX when done right.

— Magefine engineering conseil: use ACLs and events liberally. Keep your integration points small and your contrat de service clear so partners and future-you can interact safely with client credit data.