Balance display, transaction history and checkout redemption done right
Magento ships a fully featured store credit system through Magento_CustomerBalance, rendered in Luma through legacy blocks. Hyvä themes usually drop that output entirely, so it has to be rebuilt as a view model, a GraphQL query and an Alpine component in checkout. This article walks through the full path from balance display to redemption.
Table of Contents
- 1. Store Credit, Gift Card and Coupon: What Magento Actually Distinguishes
- 2. Balance Display in the Customer Account: Porting Block Logic to a View Model
- 3. Loading Balance and History via GraphQL
- 4. Rendering Balance History: Refund, Order and Admin Adjustment
- 5. Checkout Redemption: An Alpine Component With a Use Balance Toggle
- 6. Interaction With the Coupon Field: Application Order and Exclusion UX
- 7. private content / customer-data.js: Keeping Balance in Its Own Section
- 8. Multi-Currency and Website Scope Pitfalls With Store Credit
- 9. Edge Cases: Balance Above Order Total, Remaining Balance and Blocked Combinations
- 10. Summary
- 11. FAQ
1. Store Credit, Gift Card and Coupon: What Magento Actually Distinguishes
Merchants and shoppers alike tend to lump everything that lowers an order total under the word gift card. Magento, however, keeps several technically separate concepts that a Hyvä theme has to treat differently. Store credit, implemented by Magento_CustomerBalance, is a balance held on the customer account, typically created through a return, a manual admin adjustment, or a direct purchase of credit. It is clearly distinct from a coupon code coming out of the SalesRule module, entered as a string in checkout and tied to a cart price rule.
On top of that, Magento_GiftCardAccount covers physical or digital gift cards with their own redemption code, which when redeemed can ultimately be converted into store credit as well. So when a Hyvä customer account talks about a balance, it almost always means the value living in Magento_CustomerBalance, while gift card can refer to either a physical card or, loosely, a coupon depending on context. This distinction is not pedantry, it determines which backend model, which database table and which GraphQL endpoint is actually responsible for a given requirement.
Configuration lives under Stores > Configuration > Customers > Customer Configuration > Customer Balance Options, covering settings such as automatic refund to balance or balance visibility. Those values belong in the Hyvä view model, not hardcoded into a template, so merchants keep control over visibility from the admin panel.
2. Balance Display in the Customer Account: Porting Block Logic to a View Model
In Luma, Magento\CustomerBalance\Block\Info feeds the balance to the template. Hyvä does not reuse that block, instead a lean ArgumentInterface view model is implemented that loads directly from the Balance model and respects website scope. The balance must never be cached against the customer entity itself, since it changes after every order or admin adjustment and needs to stay current in the account view.
The view model below wraps loading and formatting, and can be reused both on the dashboard and on a dedicated balance page. Formatting deliberately goes through PricingHelper so the output stays consistent with the rest of the theme's price rendering.
<?php
declare(strict_types=1);
namespace Mironsoft\CustomerBalance\ViewModel;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\CustomerBalance\Model\Balance;
use Magento\CustomerBalance\Model\BalanceFactory;
use Magento\Framework\Pricing\Helper\Data as PricingHelper;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\StoreManagerInterface;
/**
* Provides the current store credit balance for the Hyvä customer account.
* Wraps loading and formatting so templates stay free of model logic.
*/
class StoreCreditBalance implements ArgumentInterface
{
/**
* @param CustomerSession $customerSession Current customer session
* @param BalanceFactory $balanceFactory Factory for the Balance model
* @param PricingHelper $pricingHelper Currency amount formatting
* @param StoreManagerInterface $storeManager Access to the active website scope
*/
public function __construct(
private readonly CustomerSession $customerSession,
private readonly BalanceFactory $balanceFactory,
private readonly PricingHelper $pricingHelper,
private readonly StoreManagerInterface $storeManager,
) {
}
/**
* Loads the current balance for the logged in customer in the active website scope.
*
* @return float
*/
public function getBalanceAmount(): float
{
if (!$this->customerSession->isLoggedIn()) {
return 0.0;
}
/** @var Balance $balance */
$balance = $this->balanceFactory->create();
$balance->setCustomerId((int) $this->customerSession->getCustomerId());
$balance->setWebsiteId((int) $this->storeManager->getWebsite()->getId());
$balance->loadByCustomer();
return (float) $balance->getAmount();
}
/**
* Formats the balance as a currency string for template output.
*
* @return string
*/
public function getFormattedBalance(): string
{
return $this->pricingHelper->currency($this->getBalanceAmount(), true, false);
}
/**
* Checks whether a positive balance exists at all.
*
* @return bool
*/
public function hasBalance(): bool
{
return $this->getBalanceAmount() > 0.0;
}
}
3. Loading Balance and History via GraphQL
For a headless or PWA-leaning Hyvä setup, direct model access inside a view model is not always the right layer, especially when balance and history need to be fetched asynchronously. Adobe Commerce ships Magento_CustomerBalanceGraphQl with a store_credit field on the Customer type. Plain Magento Open Source does not include that field, so a custom resolver has to be added, internally calling the same Balance class the view model already uses.
History itself is not exposed as a GraphQL field by Magento at all and almost always needs a custom resolver, typically built on top of Magento\CustomerBalance\Model\ResourceModel\Balance\History\CollectionFactory. For caching strategy it matters that this query always returns personalized, customer-specific data and must never be served from the full page cache, only fetched authenticated via the customer token.
query CustomerStoreCredit {
customer {
store_credit {
enabled
current_balance {
value
currency
}
}
}
}
# Extending this with history requires a custom resolver,
# since Magento does not natively expose that data via GraphQL.
query CustomerStoreCreditHistory($pageSize: Int = 20) {
customer {
store_credit {
current_balance {
value
currency
}
}
balance_history(pageSize: $pageSize) {
items {
action_type
balance_delta {
value
currency
}
additional_info
created_at
}
}
}
}
4. Rendering Balance History: Refund, Order and Admin Adjustment
Customers want to understand how their balance came to be. History entries in Magento_CustomerBalance carry a type that roughly groups into three categories: refund from returns or cancellations, order from redemption during checkout, and admin adjustment from manual bookings by support staff. In the Hyvä template it pays off to translate these types into a dedicated badge mapping, so the table reads clearly instead of exposing internal codes such as used or updated.
For larger histories, pagination is mandatory, since some customers accumulate dozens of entries over the years. The rendering pattern below relies on a separate StoreCreditHistory view model that loads, sorts and paginates the collection, while the template stays responsible for presentation only.
<?php /** @var \Mironsoft\CustomerBalance\ViewModel\StoreCreditHistory $historyViewModel */ ?>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 text-left">
<th class="py-2 pr-4">Date</th>
<th class="py-2 pr-4">Type</th>
<th class="py-2 pr-4">Amount</th>
<th class="py-2">Balance after</th>
</tr>
</thead>
<tbody>
<?php foreach ($historyViewModel->getHistoryItems() as $item): ?>
<tr class="border-b border-gray-100">
<td class="py-2 pr-4"><?= $escaper->escapeHtml($item->getFormattedDate()) ?></td>
<td class="py-2 pr-4">
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs <?= $escaper->escapeHtmlAttr($item->getTypeBadgeClass()) ?>">
<?= $escaper->escapeHtml($item->getTypeLabel()) ?>
</span>
</td>
<td class="py-2 pr-4"><?= $escaper->escapeHtml($item->getFormattedDelta()) ?></td>
<td class="py-2"><?= $escaper->escapeHtml($item->getFormattedBalanceAfter()) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
5. Checkout Redemption: An Alpine Component With a Use Balance Toggle
In checkout, store credit should never appear as a standalone form field, it needs to be a clearly recognizable toggle that shows the available balance and immediately reflects its effect in the totals area. An Alpine component fits this perfectly since it requires no extra JavaScript bundle and slots directly into existing Hyvä checkout templates.
Toggling sends a request to a dedicated controller or resolver that sets the use_store_credit flag on the quote. Totals then need to reload, which is exactly what the customer-data.js event reload-customer-section-data is for, keeping mini cart and checkout summary consistent without a full page reload.
<div
x-data="{
useStoreCredit: <?= $storeCreditViewModel->hasBalance() && $storeCreditViewModel->isApplied() ? 'true' : 'false' ?>,
balance: <?= (float) $storeCreditViewModel->getBalanceAmount() ?>,
applying: false,
async toggle() {
this.applying = true;
try {
await hyva.postForm('<?= $escaper->escapeUrl($block->getUrl('storecredit/checkout/apply')) ?>', {
use_store_credit: this.useStoreCredit ? 0 : 1
});
this.useStoreCredit = !this.useStoreCredit;
window.dispatchEvent(new CustomEvent('reload-customer-section-data', {
detail: ['cart']
}));
} finally {
this.applying = false;
}
}
}"
class="rounded border border-gray-200 p-4"
>
<label class="flex items-center gap-3">
<input type="checkbox" x-model="useStoreCredit" x-on:change="toggle()" :disabled="applying">
<span>Use store credit (<span x-text="balance.toFixed(2)"></span> available)</span>
</label>
</div>
6. Interaction With the Coupon Field: Application Order and Exclusion UX
Store credit and coupon codes are two independent collectors from the quote totals system: Magento\SalesRule\Model\Total\Quote\Discount for the coupon, Magento\CustomerBalance\Model\Total\Quote\Customerbalance for the balance. In the default sort order, the coupon acts on the subtotal first, then store credit reduces the already discounted amount. For the UI this means any change to the coupon field must trigger a recalculation of the applied store credit display, not the other way around.
Contrary to a common assumption, Magento core has no native setting that mutually excludes store credit and coupon codes. Both can technically be applied at the same time. Merchants who need an exclusion for business reasons, for instance to keep promotional discounts from stacking with customer balances, have to implement it through a custom observer on sales_quote_collect_totals_before or validation inside the coupon apply controller, and reflect that in the UI accordingly rather than relying on a core switch.
For UX, a clear layout priority helps: coupon field first, store credit toggle directly beneath it, with a hint text whenever a combination is blocked for business reasons. That keeps it understandable for shoppers why a field appears disabled, instead of the application silently failing.
// Example totals response after applying both a coupon code and store credit.
// The discount reduces the subtotal first, customerbalance is applied afterwards.
{
"totals": {
"subtotal": 129.90,
"discount": -10.00,
"customerbalance": -25.00,
"shipping": 4.90,
"tax": 18.02,
"grand_total": 116.82
}
}
7. private content / customer-data.js: Keeping Balance in Its Own Section
Store credit is inherently private, customer-specific content and therefore belongs in its own customer-data.js section rather than in server-cached page content. Only that separation keeps the full page cache valid for every visitor while the balance still shows up current for each individual customer. The section is registered through SectionPoolInterface and returns a simple array with balance and formatted value.
After any action that changes the balance, such as checkout redemption or a simulated admin push, the section needs targeted invalidation so it gets reloaded from the server on the next request. A global reload of every section is usually unnecessarily expensive and should be avoided when only the balance area is affected.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Customer\CustomerData\SectionPoolInterface">
<arguments>
<argument name="sectionSourceMap" xsi:type="array">
<item name="storecredit" xsi:type="string">Mironsoft\CustomerBalance\CustomerData\StoreCredit</item>
</argument>
</arguments>
</type>
</config>
8. Multi-Currency and Website Scope Pitfalls With Store Credit
The store credit balance is stored per customer and per website, not per store view and not per currency. When a shop runs multiple websites with different base currencies, each website carries its own, fully independent balance that is neither transferred nor converted automatically. A customer who accumulated balance on a German website will see a zero balance on a separate Swiss or US website with its own website id, even though both share the same customer account.
Within a single website that serves multiple store views and display currencies, a different problem shows up: the stored balance always lives in the website base currency, while the frontend displays it in whatever currency the current store view is configured with. Conversion at display time can create a perceived mismatch between the amount shown in the customer account and the amount actually applied in checkout whenever an exchange rate update happened between the two requests.
In practice, it pays to always pass the base currency explicitly through the view model and the GraphQL response, and to make it transparent in the UI which currency the balance is actually held in, rather than relying solely on the currently active store view currency. That avoids support tickets when customers switch between store views with different display currencies.
9. Edge Cases: Balance Above Order Total, Remaining Balance and Blocked Combinations
If the available balance exceeds the order total, the Customerbalance total collector automatically caps the applied amount at the outstanding total, so the grand total can never go negative. The remaining, unused balance stays untouched on the customer account and is available for the next order. The UI should explicitly communicate this case, for instance with a note about the remaining balance left after the order completes, so customers do not assume their entire balance was consumed.
A second edge case involves payment methods when balance covers the order in full: if the balance covers the entire order total including shipping and tax, a conventional payment method becomes unnecessary, and Magento internally offers the free payment method for that case, which needs to be surfaced as its own visible checkout step rather than leaving the checkout stuck with no payment option to select.
Since, as covered in the previous section, there is no native exclusion between coupon codes and store credit, a custom-built exclusion also has to cover all the edge cases cleanly, for example a customer who first activates their balance and then tries to enter a mutually exclusive coupon code afterwards. The UI should disable the other field at that moment or show a clear error message, rather than silently rejecting the request server side with no feedback.
| Transaction Type | Typical Trigger | Effect on Balance | Frontend Visibility |
|---|---|---|---|
| Refund | Return or cancellation booked back to balance | Increases balance | Immediately in history and customer-data.js section |
| Order | Redeeming balance during checkout | Decreases balance | Visible in history right after order completion |
| Admin adjustment | Manual booking by support staff | Increases or decreases balance | Appears with a comment field in history |
| Balance activation | Direct purchase or activation of credit | Increases balance | Visible right after payment confirmation |
| Cancelled order | Reversal on order cancel after redemption | Increases balance | Immediately in history after cancellation |
| Expiration (if configured) | Automatic expiry after a set period | Decreases balance | Own history entry with an expiration note |
Mironsoft
Hyvä theme development and Luma migration
Still running Luma, or a Hyvä theme that just doesn't feel right?
We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.
Luma-to-Hyvä Migration
Move an existing shop to Hyvä in a structured way, without losing functionality.
Custom Theme Development
Build a custom Hyvä theme from scratch based on your design.
Performance Optimization
Improve Core Web Vitals and load times in the Hyvä frontend with purpose.
10. Summary
Store Credit UI
Store credit is not a coupon code
Magento_CustomerBalance manages customer balances separately from SalesRule coupons and gift cards. That split determines which model, table and GraphQL endpoint applies.
The GraphQL field is Commerce-specific
The native store_credit field comes from Magento_CustomerBalanceGraphQl. Open Source needs a custom resolver for both balance and history.
Mind the checkout application order
The coupon reduces the subtotal first, store credit then acts on the already discounted amount. No native mutual exclusion exists between the two.
Balance belongs in its own section
As a private, customer-specific customer-data.js section, the full page cache stays valid while the balance updates precisely without a full reload.