Store Credit
Hyvä POS is in closed beta
Hyvä POS is currently in a closed beta (pilot phase) with a small group of merchants. It is not yet generally available: the App Store release follows the pilot, and features and configuration may still change - possibly in backwards-incompatible ways - before the general release. Want to take part? Sign up at hyva.io/pos.
A cashier attaches a customer to a sale, sees their store-credit balance, and applies part of it as a tender. Later, a return settles back to store credit instead of cash or card. A store-credit bridge translates those flows to your wallet extension: one class that reads the balance, validates the apply, debits the wallet when the order saves, and credits it when a refund memo saves. This page covers that contract and a worked provider; module scaffolding, the registration guard, and installation are on Building a Bridge - build the module shell there first.
The Four Flows
| Flow | Your method | What your bridge does |
|---|---|---|
| Checkout apply (tender split) | CreditProviderInterface::applyCredit |
Validate only. Reject non-positive amounts, never approve more than the live balance, report the approved amount via getAmountApplied(). Do NOT touch the wallet. |
| Checkout debit (order save) | WalletDebitProviderInterface::debitWalletForOrder |
The one wallet write for the credit the sale spent. Amount is in order currency. Throw LocalizedException on failure. |
| Refund to store credit (return / memo) | MemoCreditIssuerInterface::prepareRefundMemo + issueRefundToWallet |
Credit the wallet inside the memo-save transaction; return the base-currency amount credited (> 0). Throw to roll the whole memo save back. |
| Standalone refund | CreditProviderInterface::refundCredit |
Credit the source directly for the cashier's ad-hoc "refund to credit" action. Reject non-positive amounts; return a result, do not throw. |
The Interfaces
All three ship in Hyva\Pos\Api. One class usually implements them all. Error messages set via setErrorMessage() are shown to the cashier verbatim - keep them human-readable, never raw exception text.
namespace Hyva\Pos\Api;
use Hyva\Pos\Api\Data\CreditApplyRequestInterface;
use Hyva\Pos\Api\Data\CreditApplyResultInterface;
use Hyva\Pos\Api\Data\CreditBalanceInterface;
use Hyva\Pos\Api\Data\CreditRefundRequestInterface;
interface CreditProviderInterface
{
/** @return CreditBalanceInterface[] All available credits for a customer. */
public function getCustomerCredits(int $customerId): array;
/** Check a gift card balance by code. A store-credit provider returns null. */
public function checkGiftCard(string $code): ?CreditBalanceInterface;
/** Validate only: reject non-positive amounts, never approve more than the live balance. getAmountApplied() is authoritative for the tender split. */
public function applyCredit(CreditApplyRequestInterface $request): CreditApplyResultInterface;
/** Standalone "refund to credit" endpoint - NOT the path for a POS refund that settles into store credit (that is MemoCreditIssuerInterface). Reject non-positive amounts. */
public function refundCredit(CreditRefundRequestInterface $request): CreditApplyResultInterface;
/** The provider identifier (e.g. "acme_store_credit"). */
public function getProviderCode(): string;
/** Must return one of: "store_credit", "gift_card", "reward_points". */
public function getFeatureType(): string;
}
The two write capabilities are core-dispatched. DispatchWalletDebitPlugin calls the debit during order save whenever a new order carries extension_attributes.customer_balance_amount > 0 and a pos_sale_id; DispatchMemoCreditIssuancePlugin drives the memo pair around the credit-memo save whenever a memo carries the POS refund-to-store-credit marker.
namespace Hyva\Pos\Api;
use Magento\Sales\Api\Data\CreditmemoInterface;
use Magento\Sales\Api\Data\OrderInterface;
interface WalletDebitProviderInterface
{
/** Debit the wallet for credit a POS sale spent. Called AFTER the order row exists; idempotency (one debit per pos_sale_id) is core-owned. Amount is in order currency. */
public function debitWalletForOrder(OrderInterface $order, float $amount): void;
}
interface MemoCreditIssuerInterface
{
/** Optional preparation hook, called on the still-unsaved memo BEFORE the repository save, inside the dispatch transaction. Most vendors leave this empty. */
public function prepareRefundMemo(CreditmemoInterface $creditmemo): void;
/** Issue the refund-to-credit for a JUST-SAVED memo, inside the same DB transaction as the memo save. Return the base-currency amount actually credited (> 0). */
public function issueRefundToWallet(CreditmemoInterface $creditmemo, OrderInterface $order): float;
}
Contract Rules
| The CORE owns (never duplicate it) | A bridge must NEVER |
|---|---|
| The DB transaction around the memo save (memo persisted implies customer credited) | Open its own begin/commit for these flows |
The idempotency ledgers: hyva_pos_wallet_debit_ledger (one debit per pos_sale_id) and hyva_pos_memo_credit_ledger (one issuance per memo) |
Keep its own idempotency records for these flows |
The pos_store_credit_issued confirmation column |
Write pos_store_credit_issued |
| Active-provider resolution and the registered-customer / new-order / zero-amount guards | Self-guard with instanceof against another bridge |
Driving prepareRefundMemo → save → issueRefundToWallet |
Register its own CreditmemoRepositoryInterface plugin for refund-to-credit |
Driving debitWalletForOrder on order save |
Register its own OrderRepositoryInterface plugin to debit at checkout |
Failure semantics differ by direction, deliberately:
- Refund to credit (
issueRefundToWallet): a thrown exception rolls the memo save back. The customer is never told a refund succeeded when the wallet write failed. - Checkout debit (
debitWalletForOrder): a thrown exception is logged and the order still saves - an order must never be lost to a wallet hiccup. The ledger row stays unwritten on failure, so a later re-save of the same sale retries the debit. Throw anyway (for example, on insufficient balance); the core decides what to do with it.
Refund-to-store-credit issues NEW credit for the full memo total regardless of how the original order was paid - it doubles as the goodwill / damaged-goods path, so there is no prior-balance check on that direction. Guest orders never reach your provider; the dispatchers and the return-wizard preflight reject them upstream.
Build It
1. Implement the Provider
The skeleton follows the vendor-API pattern of the shipped Amasty bridge: prepareRefundMemo is empty and every wallet mutation is a call to your vendor's own service. Replace the // your vendor call lines.
<?php
declare(strict_types=1);
namespace Acme\PosBridge\Model;
use Hyva\Pos\Api\{CreditProviderInterface, MemoCreditIssuerInterface, WalletDebitProviderInterface};
use Hyva\Pos\Api\Data\{CreditApplyRequestInterface, CreditApplyResultInterface, CreditBalanceInterface, CreditRefundRequestInterface};
use Hyva\Pos\Model\Data\{CreditApplyResult, CreditBalance};
use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\Data\{CreditmemoInterface, OrderInterface};
class StoreCreditProvider implements CreditProviderInterface, MemoCreditIssuerInterface, WalletDebitProviderInterface
{
private const PROVIDER_CODE = 'acme_store_credit';
public function __construct(
private readonly \Acme\Wallet\Api\WalletManagementInterface $wallet // your vendor service
) {
}
public function getProviderCode(): string { return self::PROVIDER_CODE; }
public function getFeatureType(): string { return 'store_credit'; }
public function checkGiftCard(string $code): ?CreditBalanceInterface { return null; }
public function getCustomerCredits(int $customerId): array
{
$balance = $this->wallet->getBalance($customerId); // your vendor call
return $balance > 0.0 ? [new CreditBalance(['type' => 'store_credit', 'provider' => self::PROVIDER_CODE,
'label' => 'Store Credit', 'balance' => $balance, 'currency' => 'EUR', 'is_redeemable' => true])] : [];
}
public function applyCredit(CreditApplyRequestInterface $request): CreditApplyResultInterface
{
$result = new CreditApplyResult();
$customerId = $request->getCustomerId();
$amount = $request->getAmount();
if ($customerId === null || $amount <= 0.0) {
return $result->setSuccess(false)->setAmountApplied(0.0)->setErrorMessage('Amount must be greater than zero');
}
$balance = $this->wallet->getBalance($customerId); // your vendor call: live balance
if ($balance < $amount) {
return $result->setSuccess(false)->setAmountApplied(0.0)->setRemainingBalance($balance)->setErrorMessage('Insufficient store credit balance');
}
// Validate only - the wallet write happens later, in debitWalletForOrder.
return $result->setSuccess(true)->setAmountApplied($amount)->setRemainingBalance($balance - $amount);
}
public function refundCredit(CreditRefundRequestInterface $request): CreditApplyResultInterface
{
$result = new CreditApplyResult();
$customerId = (int) ($request->getCustomerId() ?? 0);
if ($customerId <= 0) {
return $result->setSuccess(false)->setAmountApplied(0.0)->setErrorMessage('Customer ID is required for store credit');
}
if ($request->getAmount() <= 0.0) {
return $result->setSuccess(false)->setAmountApplied(0.0)->setErrorMessage('Refund amount must be greater than zero');
}
$this->wallet->credit($customerId, $request->getAmount()); // your vendor call: add credit
return $result->setSuccess(true)->setAmountApplied($request->getAmount());
}
public function prepareRefundMemo(CreditmemoInterface $creditmemo): void
{
// Nothing to prepare - this bridge credits the wallet in issueRefundToWallet, after the save.
}
public function issueRefundToWallet(CreditmemoInterface $creditmemo, OrderInterface $order): float
{
$amount = round((float) $creditmemo->getBaseGrandTotal(), 2);
if ($amount <= 0.0) {
throw new LocalizedException(__('This refund has no amount to credit.'));
}
$this->wallet->credit((int) $order->getCustomerId(), $amount); // your vendor call: add credit (base currency)
return $amount;
}
public function debitWalletForOrder(OrderInterface $order, float $amount): void
{
$customerId = (int) $order->getCustomerId();
if ($this->wallet->getBalance($customerId) < $amount) { // your vendor call: live balance
throw new LocalizedException(__('Insufficient store credit balance; wallet not debited.'));
}
$this->wallet->debit($customerId, $amount); // your vendor call: subtract (order currency)
// Vendor order-column bookkeeping goes here too - see Gotchas.
}
}
If your wallet is Adobe Commerce-style - the platform itself writes the balance during the memo save via its own observer - invert the memo pair: prepareRefundMemo sets the native refund fields on the still-unsaved memo so the platform's save-after observer performs the write, and issueRefundToWallet only reads the credited figure back and returns it, with no second write. The shipped AdobeCommerceStoreCredit/Model/StoreCreditProvider is the reference for this native-pipeline variant.
2. Register It in the Pool
<type name="Hyva\Pos\Model\CreditProviderPool">
<arguments>
<argument name="providers" xsi:type="array">
<item name="acme_store_credit" xsi:type="string">Acme\PosBridge\Model\StoreCreditProvider</item>
</argument>
</arguments>
</type>
Use xsi:type="string" (the class name), not object: the pool resolves string entries lazily inside a try/catch, so a bridge whose vendor module is absent is skipped instead of crashing DI compilation. getFeatureType() returning store_credit is what routes the provider to the store-credit feature.
3. Activate and Verify
Install per Building a Bridge, then select your provider as Store Credit Provider under Stores → Configuration → Hyvä POS → Advanced → Credit & Return Providers. Leaving the field empty auto-detects the first installed store-credit provider. Then walk the flows at the register:
- Attach a customer with a funded wallet, apply credit as a tender, and complete the sale. The vendor wallet must decrease by the applied amount, exactly once - a re-save of the order must not debit again.
- Run a return and settle it to store credit. The wallet must increase by the memo total and the credit memo must be saved - both or neither.
- Trigger the standalone "refund to credit" action on an order. The wallet must increase and the cashier sees the result immediately.
Prove It
The core ships an executable conformance kit. Extend Hyva\Pos\TestFramework\Conformance\AbstractCreditProviderConformanceTest in your unit suite and wire your vendor mocks:
<?php
declare(strict_types=1);
namespace Acme\PosBridge\Test\Unit;
use Acme\PosBridge\Model\StoreCreditProvider;
use Hyva\Pos\Api\{CreditProviderInterface, MemoCreditIssuerInterface, WalletDebitProviderInterface};
use Hyva\Pos\TestFramework\Conformance\AbstractCreditProviderConformanceTest;
use Magento\Sales\Api\Data\{CreditmemoInterface, OrderInterface};
class StoreCreditProviderConformanceTest extends AbstractCreditProviderConformanceTest
{
private \PHPUnit\Framework\MockObject\MockObject $wallet;
protected function createProvider(): CreditProviderInterface
{
$this->wallet = $this->createMock(\Acme\Wallet\Api\WalletManagementInterface::class);
return new StoreCreditProvider($this->wallet);
}
protected function arrangeWalletDebitSucceeds(WalletDebitProviderInterface $provider, OrderInterface $order): void
{
$this->wallet->method('getBalance')->willReturn(100.0); // fund the wallet above debitAmount()
}
protected function arrangeMemoIssuanceFails(MemoCreditIssuerInterface $provider, CreditmemoInterface $memo, OrderInterface $order): void
{
$this->wallet->method('credit')->willThrowException(new \RuntimeException('wallet unavailable'));
}
}
- The
CreditProviderInterfacescenarios always run: provider code and feature type are valid, andrefundCreditrejects a missing customer and non-positive amounts. Scenarios forMemoCreditIssuerInterfaceandWalletDebitProviderInterfacerun only when your provider implements them; otherwise they skip automatically. Thearrange*hooks (arrangeMemoIssuanceSucceeds,arrangeMemoIssuanceFails,arrangeWalletDebitSucceeds,arrangeWalletDebitInsufficientFunds) default to no-ops - override only where your vendor double must be stubbed to reach the branch. The insufficient-funds no-op already suffices when your balance read defaults to zero, as above;makeSavedRefundMemo(),makeDebitOrder(),debitAmount()andissuedAmount()are overridable too if your methods read fields the generic doubles do not expose. - Assertions are message-pattern based, never exact-string, so your cashier-facing wording stays yours (and localizable). Two patterns are fixed: the missing-customer refund message must mention the customer (
/customer/i), and the insufficient-funds debit exception message must contain "insufficient" (/[Ii]nsufficient/).
Gotchas
- Consumption-return is optional vendor sugar, not contract. Crediting back what a POS order originally spent when a plain (un-marked) credit memo refunds it is a vendor-specific enhancement - the Amasty bridge ships it by reading the core debit ledgers. Refund-to-store-credit via
MemoCreditIssuerInterfaceis the contracted flow; skip the sugar unless your merchants ask for it. - Vendor order-column bookkeeping belongs inside
debitWalletForOrder. If your extension mirrors spent credit onto order columns (likecustomer_balance_amount), write them there - the core dispatcher never touches vendor columns. The dispatcher has already saved the order row when your method runs, sosetDataalone lands only on the in-memory instance; persist the columns with a targetedsaveAttribute(see the Adobe Commerce bridge) rather than a full re-save. - A provider that mutates the wallet inside
applyCreditdouble-debits. Apply validates; the one write isdebitWalletForOrder, and the core ledger makes it happen exactly once per sale.