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: it answers what a cart may spend, debits the wallet when the order saves, and has the vendor credit the wallet when a refund memo is booked. 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.
This page also describes the CreditProviderInterface methods every credit provider implements. The Gift Cards and Reward Points pages refer back to it and add what differs for their kind.
The Flows
| Flow | Your method | What your bridge does |
|---|---|---|
| What a cart may spend | CreditProviderInterface::applicableCredit |
Answer supported (maximum, minimum, per-line eligibility, the reason), unsupported or failed from the vendor's own rules. Create no quote and never touch the wallet. |
| Checkout debit (order save) | WalletDebitProviderInterface::debitWalletForOrder, mapOrderTotals |
Inside the order's transaction: lock the wallet row, take what it covers, answer a debit result, then write the vendor's columns at the debited figure. Throw only when the vendor write fails. |
| Refund to store credit, and the credit a refunded sale spent | MemoCreditIssuerInterface |
Hand the refund to the vendor's own pipeline, answer whether the vendor credited the memo, repair a memo it provably did not credit, and answer the vendor's "refund to store credit automatically" setting. |
| 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 credit on a document | CreditProviderInterface::readRefundLegs, readInvoiceLegs |
Translate the vendor's credit memo and invoice columns into credit legs. |
The Interfaces
All three ship in Hyva\Pos\Api. One class usually implements them all.
namespace Hyva\Pos\Api;
use Hyva\Pos\Api\Data\CreditApplicableInterface;
use Hyva\Pos\Api\Data\CreditApplicableRequestInterface;
use Hyva\Pos\Api\Data\CreditApplyResultInterface;
use Hyva\Pos\Api\Data\CreditBalanceInterface;
use Hyva\Pos\Api\Data\CreditDebitResultInterface;
use Hyva\Pos\Api\Data\CreditRefundRequestInterface;
use Hyva\Pos\Api\Data\VendorCreditOutcome;
use Magento\Sales\Api\Data\CreditmemoCreationArgumentsInterface;
use Magento\Sales\Api\Data\CreditmemoInterface;
use Magento\Sales\Api\Data\InvoiceInterface;
use Magento\Sales\Api\Data\OrderInterface;
interface CreditProviderInterface
{
/** @return CreditBalanceInterface[] */
public function getCustomerCredits(int $customerId): array;
public function checkGiftCard(string $code): ?CreditBalanceInterface;
public function applicableCredit(CreditApplicableRequestInterface $request): CreditApplicableInterface;
/** @return string "tender" or "discount" */
public function appliesAs(string $kind): string;
public function refundCredit(CreditRefundRequestInterface $request): CreditApplyResultInterface;
public function getProviderCode(): string;
/** @return string "store_credit", "gift_card" or "reward_points" */
public function getFeatureType(): string;
/** @return \Hyva\Pos\Api\Data\CreditLegInterface[] */
public function readRefundLegs(CreditmemoInterface $creditmemo): array;
/** @return \Hyva\Pos\Api\Data\CreditLegInterface[] */
public function readInvoiceLegs(InvoiceInterface $invoice): array;
}
interface WalletDebitProviderInterface
{
public function debitWalletForOrder(OrderInterface $order, float $amount, bool $partialAllowed): CreditDebitResultInterface;
/** @param \Hyva\Pos\Api\Data\CreditLegInterface[] $legs */
public function mapOrderTotals(OrderInterface $order, array $legs): void;
}
interface MemoCreditIssuerInterface
{
public function prepareRefundArguments(CreditmemoCreationArgumentsInterface $arguments, float $baseAmount): void;
public function prepareRefundMemo(CreditmemoInterface $creditmemo, bool $toStoreCredit): void;
public function creditedByVendor(CreditmemoInterface $saved, OrderInterface $order): VendorCreditOutcome;
public function issueRefundToWallet(CreditmemoInterface $saved, OrderInterface $order): float;
public function refundsToStoreCreditByDefault(): bool;
}
Error messages set via setErrorMessage() and reasons set on an answer are shown to the cashier verbatim. Keep them human-readable and never echo raw exception text.
The Shared Credit Contract
These CreditProviderInterface methods behave the same for store credit, gift cards and reward points.
Identity and Balances
getProviderCode()returns a stable identifier such asacme_store_credit. The admin provider selection keys on it.getFeatureType()returnsstore_credit,gift_cardorreward_points. It routes the provider to its feature.getCustomerCredits(int $customerId)returns the customer's balances asCreditBalanceInterfacerows (type,provider,label,balance,currency,is_redeemable, and for pointscurrency_valueandpoints_per_currency). Return[]for a zero balance.checkGiftCard(string $code)looks a gift card up by code. A store-credit or points provider returnsnull; the gift-card behavior is on Gift Cards.
appliesAs: How the Kind Applies
appliesAs(string $kind) answers tender (the credit pays part of the total, after tax) or discount (it lowers the price before tax). It is a static classification, never a live read. The core asks it when the applicable read failed or answered nothing to spend, and when a debit failed, so a leg keeps its kind's treatment whatever went wrong: a tender that could not be taken leaves the amount due, and a points discount that could not be taken stays as issued. Store credit and gift cards are tenders on every bridge. Answer tender for a kind your provider does not handle.
applicableCredit: What a Cart May Spend
applicableCredit(CreditApplicableRequestInterface $request) answers what a cart may spend of your credit. The register asks it on Apply and again at checkout through POST /V1/pos/credits/applicable, and the core asks it again at order save with the order's own figures before any debit.
The request carries the cart the register rang: credit_type, the customer, a gift card's code, the store view, the subtotal excluding and including tax, the discount and its tax compensation, shipping, the tax, the cart's worth before credit (grand_total), the lines (CreditApplicableLineInterface: sku, qty, row totals excluding and including tax, the line discount) and applied_credits, the legs other credits already apply to the cart. Answer from your vendor's own rules and calculators. Never create a quote and never mutate a wallet here.
The answer (CreditApplicableInterface) has one of three outcomes:
supported: you answered. Setmax_amount, the most the cart may spend, capped by the live balance and by every vendor rule you answer;min_amount;lines, oneCreditLineEligibilityInterfacerow per request line with a reason on each ineligible line; onereasonsentence for the cashier when the answer is below the balance or nothing may be spent;amount_step, the smallest amount that may be applied and the multiple every applied amount must be (null means cents); andapplies_as. Reward points also setpoints_rate,points_granularity,max_points,min_pointsandround_rule. A debit of the maximum, and of any multiple ofamount_stepup to it, must be taken online.unsupported: your bridge does not answer this kind's rules. The credit cannot be applied at the counter.failed: the read errored. A thrown exception is read asfailed.
The core holds the answer to the contract. An unsupported or failed answer carries max_amount 0 whatever the provider set. A supported answer in which no request line is eligible carries max_amount 0 and is not redeemable, with "No product in this sale may be paid with ..." when you gave no reason. A supported maximum is capped at what the cart still owes after applied_credits. Answer your vendor's rules and balance only.
A vendor rule that restricts the credit to some products is answered per line: every request line gets an eligibility row, an ineligible one with a reason, and an ineligible line never counts towards the maximum. A cart with no eligible line is a rule you answered, so answer supported with max_amount 0 and the reason; the register shows the credit as unavailable for this sale. The register sends no quote item, only each line's SKU and figures. Hyva\Pos\Model\CreditDebit\CartLineCatalog::product($sku, $storeId) answers what a vendor's product rule matches on (the line's SKU, its configurable parent's for a variant not visible on its own, and their category ids), so a bridge re-implements its vendor's checker on that.
What the reference bridges answer for store credit. Adobe Commerce: the balance, since Commerce has no tax or product rule for store credit. Amasty: the balance capped at the share Amasty's own strategies let store credit pay. Without Apply Store Credit to Specific Products that is the grand total, less the tax when Allow applying Store Credit on Tax is No and less the shipping when Allow applying Store Credit on Shipping is No (99.17 on a 120.00 cart with 20.83 tax, with the reason "Store credit on this store does not pay for tax: it may pay 99.17."). With it, the share is the eligible lines' row totals less their discount, with their tax when tax is allowed, plus the shipping and its tax by the two flags. Credit already on the cart uses that share first.
readRefundLegs and readInvoiceLegs: The Credit on a Document
readRefundLegs(CreditmemoInterface $creditmemo) translates your vendor's credit memo columns into credit legs (CreditLegInterface): kind, provider_code, applies_as, direction (returned), base_amount and amount, and for points points and points_granularity. Answer only legs of your own kind, and [] when the memo carries none. Read what the vendor's collectors booked; compute no allocation of your own.
The core calls it in two places:
- The refund preview,
POST /V1/pos/refunds/preview, on a memo built through every collector and never saved. The core builds it on a fresh order instance inside a transaction it rolls back, with the store-credit provider'sprepareRefundArgumentsapplied when the refund goes to store credit and itsprepareRefundMemoapplied on every preview, as the settle applies them. A vendor whose memo collector reads request-scoped state that its own refund plugin fills primes that state fromprepareRefundArguments, because the preview makes no refund call. The rollback undoes database writes on the sales connection only, so a reader, like a collector, sends no email, calls no remote service and writes on no other connection. - The saved memo after a settle, to answer the booked legs, the handback and any difference from the legs the register acted on.
The refund itself is the memo's grand total before any credit collector ran. The core stamps it at total sort order 405 (pos_base_refund_gross and pos_refund_gross on sales_creditmemo), so a credit collector belongs after 405. The handback by the original tender is that refund less every tender leg.
A bridge whose vendor's memo columns have not been mapped yet throws LocalizedException; the preview then answers the error, and a settle records that the legs could not be read.
readInvoiceLegs(InvoiceInterface $invoice) is the same translation for an invoice, with the direction consumed. The core serves the result on every invoice read as extension_attributes.pos_credit_legs, and leaves a refusing bridge's legs out.
For store credit, Adobe Commerce reads customer_balance_amount (the credit the order spent, netted by Commerce's collector) plus bs_customer_bal_total_refunded (a refund to store credit); Amasty reads amstorecredit_base_amount, which covers both.
refundCredit: The Standalone Refund
refundCredit(CreditRefundRequestInterface $request) backs the cashier's ad-hoc "refund to credit" action outside the return and credit memo flow. It credits the source and returns a CreditApplyResultInterface. Reject a missing customer and non-positive amounts with a failure result; do not throw. It is not the path a POS refund or return takes into store credit; that is MemoCreditIssuerInterface, below.
The Checkout Debit
WalletDebitProviderInterface::debitWalletForOrder(OrderInterface $order, float $amount, bool $partialAllowed) debits the customer's wallet for the store credit the sale spent, in order currency.
The core drives it for a new order that carries extension_attributes.pos_store_credit_amount > 0 and a pos_sale_id, inside the database transaction that saves the order, after the order row exists:
- It asks
applicableCreditwith the order's figures and caps the request at that answer and at what the order is worth less the credit already booked.unsupportedandfailedtake nothing. A request above the maximum is refused online and asked at the maximum offline. - Your debit takes what the locked wallet covers and answers a
CreditDebitResultInterface:kind,provider_code,applies_as,requested(the$amountyou were given),debited,outcome(debited,partial,refused,unsupportedorfailed) andreason. mapOrderTotals($order, $legs)writes your vendor's own record of what was debited.- The core books the outcome on the order (the credit columns at the debited figure, a shortfall as an amount due with a status-history comment,
pos_credit_outcomeson the response) and records the ledger row, in the same transaction.
$partialAllowed is the order's pos_offline flag. Online (false), a wallet that cannot cover the request is left alone and the answer is refused; the register re-reads the balance before the tender, so this surfaces as a sync issue. On the offline drain (true), take what the wallet holds and answer partial, because the tender was already collected.
The balance check is a lock, not a read. Lock the row in your vendor's own table with Hyva\Pos\Model\CreditDebit\RowLock::lock($table, $conditions) before you read the balance, read the balance from the locked row, and make the vendor's write compute from that row. A vendor model loaded with a plain read after the lock is not enough: inside the order's transaction a plain read serves the snapshot the transaction opened with, which can predate a sale that committed while this one waited on the lock. Set the locked row on the vendor's model (or its cached entity) before the write, and keep the model from loading again on save. Two sales on one wallet then cannot both spend it.
Insufficient funds is an outcome, not an exception. Throw only when the vendor write itself fails. The core runs your debit inside a savepoint on the order's connection: a throw rolls back whatever your vendor had written before it, and the core records failed, writes no ledger row and books no credit for the leg. Nothing retries a failed debit later. The whole order save stops instead (everything rolls back and the register's queue posts the sale again) after a deadlock, a lock-wait timeout or a lost connection, when the savepoint went with the transaction, when the vendor write rolled back a nested transaction, or when it left a nested transaction open.
The dispatcher refuses store credit on a guest order before it reaches you.
mapOrderTotals: The Vendor's Record of the Debit
mapOrderTotals(OrderInterface $order, array $legs) writes the columns or record your vendor's invoice and credit memo collectors read, at the debited figure. $legs are the consumed CreditLegInterface legs of your kind; the call is skipped when nothing was debited. The order row exists, so persist with the order resource's saveAttribute() (or the vendor's own repository for a separate record), never with a repository save of the order, which would run every order-save plugin and observer again.
Since module 1.3.0 the vendor's columns are written here, at the debited figure. A bridge built for an earlier module that mapped the requested figure from a beforeSave plugin on OrderRepositoryInterface removes that plugin.
The reference bridges write: Adobe Commerce customer_balance_amount and base_customer_balance_amount; Amasty amstorecredit_amount, amstorecredit_base_amount and each line's amstorecredit_amount share (by line value after discount, the remainder on the last line). For the lock, Amasty locks amasty_store_credit by customer and sets its repository's cached entity to the locked balance; Commerce locks magento_customerbalance by customer and website and sets the locked row on the Balance model it loaded.
A kind's legs are kept as the register sent them, with nothing due, only when the store names no provider for the kind and none is installed: a hand-entered credit note. A provider the store names that is not installed fails the leg: nothing is taken and the amount due is raised.
Refund to Store Credit
A refund to store credit is booked by one call of Magento's RefundOrderInterface::execute (or RefundInvoiceInterface::execute for a gateway refund), including the vendor's own plugins around it. Inside that call Magento's order mutex holds the sales transaction over the order and memo saves. Your vendor credits the wallet either inside it (in the memo's own transaction) or right after it, in the same request. The core opens no transaction around the save and writes no wallet during it. It confirms the write after the call returns, from what your vendor persisted.
The core calls the four methods in this order:
prepareRefundArguments($arguments, $baseAmount), before the document is built, for a refund to store credit.$baseAmountis the refund before any credit netting, in base currency. A vendor whose own memo collectors book the refund to its wallet sets its creation argument here; a vendor that works from the unsaved memo leaves the arguments alone. A bridge that cannot book a refund to store credit throwsLocalizedExceptionhere, and no memo is created.-
prepareRefundMemo($creditmemo, $toStoreCredit), on the unsaved memo, inside the repository save, for every memo the module creates: its return and settle paths, an RMA bridge's one-call return and the layaway cancellation, a refund to the original tender included (with$toStoreCreditfalse). Two things are asked of it:- The store credit the refunded sale spent goes back to the wallet. A vendor whose memo collector nets that credit off the memo but returns it only for a flagged memo sets its flag and amounts here for the consumed share. A vendor whose own refund path already returns it does nothing. A memo of a sale that spent no credit is left as it is, and nothing throws: every cash refund on the store passes through here.
- With
$toStoreCredittrue, the whole refund lands in the wallet: the consumed share plus the netted total. A vendor whose save-after observer credits the wallet sets its refund fields here. ThrowLocalizedExceptionwhen there is nothing to credit; the save then stops.
A memo created outside the module (Magento's own
POST /V1/order/:id/refund, the admin) is not passed to the provider. 3.creditedByVendor($saved, $order), after the refund call returned, under the order mutex, for every store-credit leg the saved memo carries (asreadRefundLegsreads it): the whole refund on a refund to store credit, and the credit the refunded sale spent on any other channel. A vendor wallet write that failed on a refund to the original tender is therefore found on the settle's retry. Answer aVendorCreditOutcome: -creditedwith the base-currency amount, when the vendor's own persisted evidence shows it credited this memo; -not_credited, only when the saved memo shows the vendor never took the credit path; -unknownfor anything else. The settle then fails with the memo named, and nothing is written.Answer from evidence the vendor persisted together with its wallet write, never by matching history rows by amount and time: two equal partial refunds on one order would satisfy such a match. 4.
issueRefundToWallet($saved, $order), only onnot_credited: the repair write, called under the order mutex inside one transaction with the core's ledger row. Book it the way the vendor's own refund to store credit would, through the vendor's own writer where it has one, so the vendor's order-side records move with the wallet and a later partial refund does not net the repaired credit again. Credit what the memo's store-credit leg carries: the whole refund on a refund to store credit, the consumed share on a refund to the original tender. Put the memo increment id in the vendor's history message and return the base-currency amount credited (greater than 0). ThrowLocalizedExceptionto roll the repair back; the core also rolls back a write of any amount other than the leg's.
One more method sits outside that sequence. refundsToStoreCreditByDefault() answers the vendor's own "refund to store credit automatically" setting, read through the vendor's own reader. GET /V1/pos/config serves the active store-credit provider's answer as credits.refund_to_store_credit (false without a provider), and the register preselects the Store Credit channel in its refund dialog when it is true; the cashier can still pick the original tender. A bridge that cannot book a refund to store credit answers false. Adobe Commerce reads Refund Store Credit Automatically (customer/magento_customerbalance/refund_automatically, default scope only) through Magento\CustomerBalance\Helper\Data::isAutoRefundEnabled(); Amasty reads amstorecredit/general/refund_automatically through Amasty\StoreCredit\Model\ConfigProvider::isRefundAutomatically().
The Two Reference Pipelines
- Adobe Commerce. Commerce's memo collector nets the credit the order has left off every memo, but its observers return it to the wallet only for a memo carrying the refund flag the admin's "Refund Store Credit" box sets.
prepareRefundMemosets that flag (customer_balance_refund_flag) and the four refund amounts (bs_customer_bal_total_refunded,customer_bal_total_refunded,base_customer_balance_refunded,customer_balance_refunded) to the consumed share on a refund to the original tender, and to the consumed share plus the netted total on a refund to store credit. Commerce's save-after observer then credits the wallet in the memo's transaction.creditedByVendorandreadRefundLegsreadbs_customer_bal_total_refunded.issueRefundToWalletsets the same fields on the saved memo and hands it to Commerce'sCreditmemo\Balance::save, the writer its observer calls. - Amasty.
prepareRefundArgumentssetsamstorecredit_base_amounton the creation arguments. Amasty's collector nets the memo and itsafterExecuteplugin credits the wallet after the mutex committed. The bridge wraps that write in a transaction that first inserts the corehyva_pos_memo_credit_ledgerrow for the memo and stamps the history message with the memo's increment id, so the ledger row, the balance and the history row land together;creditedByVendorreads the ledger row. The vendor's customer email is held until that transaction commits.
A vendor that credits outside the memo transaction records the ledger row itself, inside the transaction that carries the vendor's write, as the Amasty bridge does. That is the one ledger write a bridge makes; the core reads the row rather than writing a second.
Every memo the module creates carries pos_register_memo in memory, and the core sets it again on a memo reloaded through the repository (a settle retry, the layaway cancellation) before it asks the provider. A bridge that wraps its vendor's wallet write for a register memo keys on that marker, not on pos_rma_id: the layaway cancellation's memo has no return id.
The Layaway Cancellation
The layaway cancellation's memo names every line at quantity zero and refunds an adjustment: the money captured plus the credit the order spent, less a fee on that gross. The cancellation calls prepareRefundMemo on this memo before Magento's refund check, which would otherwise refuse it as more than the order has paid, and the save then leaves the prepared memo alone. A vendor whose collector nets credit per refunded line only (Amasty) books the credit the order spent and has not had back here, capped at the memo's total, and takes it off the memo's grand total. The core's confirmation of the memo's store-credit leg then writes the wallet if the vendor's own write does not run on this path.
When the fee is more than the money captured, the rest of it comes out of the credit the layaway spent, and that part stays with the store. During the order cancel the core holds that part in Hyva\Pos\Model\Layaway\RetainedCredit (retainedFor($orderId), base currency). A vendor whose order cancel returns the credit an order has not had back counts it as refunded before its own cancel step reads the order, so the cancel returns only the credit still due; the Amasty bridge does this before Amasty's order_cancel_after observer. A vendor that returns only credit that was never invoiced (Commerce) needs nothing. Only store credit is held back this way: when such a fee meets a deposit that also spent a gift card or reward points, the core refuses the cancellation before anything moves.
One Writer per Refund
The memo is the only writer of a refund to store credit. An RMA bridge that processes a return in one call issues no credit of its own beside the memo, through CreditService::refund or otherwise. It creates the memo through the core's ReturnMemoSettlement (see RMA Providers), which prepares the memo and confirms every store-credit leg it carries. A second write beside a memo that already returned the consumed share credits the wallet twice for one refund.
Contract Rules
The ownership table on Building a Bridge applies in full. For store credit in particular:
- Idempotency is core-owned:
hyva_pos_wallet_debit_ledgerrecords one debit perpos_sale_id, written with the order;hyva_pos_memo_credit_ledgerrecords one issuance per memo. A bridge keeps no idempotency records and writes the memo ledger row only in the case described above. - The core books the order: the applicable cap, the order's worth cap, the shortfall and
pos_credit_outcomes. A bridge never writes the order's grand total, its amount due or the neutralpos_*credit columns, and never writespos_store_credit_issued. - Registered customers only. Refund to store credit issues new credit for the memo regardless of how the order was paid, which doubles as the goodwill and damaged-goods path.
- Failure semantics. On a refund, the memo stands once the refund call returned; a wallet write the core cannot confirm fails the settle with the memo named and the return stays open, and the retry confirms the same memo without a second write. A failed repair write rolls its transaction back. On a checkout debit, a short wallet is
refusedonline andpartialoffline, and a thrown vendor failure is recorded asfailedwith nothing taken.
Build It
1. Implement the Provider
A skeleton for a fictional Acme wallet whose vendor table is acme_wallet and whose save-after observer credits the wallet for a flagged memo. Replace the // your vendor call lines.
<?php
declare(strict_types=1);
namespace Acme\PosBridge\Model;
use Acme\Wallet\Api\WalletManagementInterface;
use Hyva\Pos\Api\{CreditProviderInterface, MemoCreditIssuerInterface, WalletDebitProviderInterface};
use Hyva\Pos\Api\Data\{CreditApplicableInterface, CreditApplicableRequestInterface, CreditApplyResultInterface,
CreditBalanceInterface, CreditDebitResultInterface, CreditLegInterface, CreditRefundRequestInterface, VendorCreditOutcome};
use Hyva\Pos\Model\CreditDebit\RowLock;
use Hyva\Pos\Model\Data\{CreditApplicable, CreditApplyResult, CreditBalance, CreditDebitResult, CreditLeg, CreditLineEligibility};
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Model\AbstractModel;
use Magento\Sales\Api\Data\{CreditmemoCreationArgumentsInterface, CreditmemoInterface, InvoiceInterface, OrderInterface};
class StoreCreditProvider implements CreditProviderInterface, WalletDebitProviderInterface, MemoCreditIssuerInterface
{
private const CODE = 'acme_store_credit';
public function __construct(
private readonly WalletManagementInterface $wallet, // your vendor service
private readonly RowLock $rowLock
) {
}
public function getProviderCode(): string { return self::CODE; }
public function getFeatureType(): string { return CreditLegInterface::KIND_STORE_CREDIT; }
public function appliesAs(string $kind): string { return CreditLegInterface::APPLIES_AS_TENDER; }
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::CODE,
'label' => 'Store Credit', 'balance' => $balance, 'is_redeemable' => true])] : [];
}
public function applicableCredit(CreditApplicableRequestInterface $request): CreditApplicableInterface
{
$answer = (new CreditApplicable())->setCreditType(CreditLegInterface::KIND_STORE_CREDIT)
->setProviderCode(self::CODE)->setAppliesAs(CreditLegInterface::APPLIES_AS_TENDER)->setMinAmount(0.0);
try {
$balance = $request->getCustomerId() === null ? 0.0 : $this->wallet->getBalance($request->getCustomerId());
} catch (\Throwable $e) {
return $answer->setOutcome(CreditApplicableInterface::OUTCOME_FAILED)->setMaxAmount(0.0)
->setIsRedeemable(false)->setReason('Store credit could not be read right now.');
}
$lines = [];
foreach ($request->getLines() as $line) { // Acme has no product rule: every line is eligible
$lines[] = (new CreditLineEligibility())->setSku($line->getSku())->setIsEligible(true);
}
return $answer->setOutcome(CreditApplicableInterface::OUTCOME_SUPPORTED)->setBalance($balance)
->setMaxAmount(floor($balance * 100) / 100)->setIsRedeemable($balance > 0.0)->setLines($lines);
}
public function debitWalletForOrder(OrderInterface $order, float $amount, bool $partialAllowed): CreditDebitResultInterface
{
$result = (new CreditDebitResult())->setKind(CreditLegInterface::KIND_STORE_CREDIT)->setProviderCode(self::CODE)
->setAppliesAs(CreditLegInterface::APPLIES_AS_TENDER)->setRequested($amount);
$customerId = (int) $order->getCustomerId();
$row = $this->rowLock->lock('acme_wallet', ['customer_id = ?' => $customerId]);
$balance = (float) ($row['balance'] ?? 0.0);
$take = $balance + 0.0001 >= $amount ? $amount : ($partialAllowed ? $balance : 0.0);
if ($take <= 0.0) {
return $result->setDebited(0.0)->setOutcome(CreditDebitResultInterface::OUTCOME_REFUSED)
->setReason('Insufficient store credit balance.');
}
// your vendor call: write the locked balance less $take; throws LocalizedException on failure
$this->wallet->writeBalance($customerId, $balance - $take, 'POS order ' . $order->getIncrementId());
return $result->setDebited($take)->setOutcome(
$take < $amount ? CreditDebitResultInterface::OUTCOME_PARTIAL : CreditDebitResultInterface::OUTCOME_DEBITED
);
}
public function mapOrderTotals(OrderInterface $order, array $legs): void
{
$amount = 0.0;
$base = 0.0;
foreach ($legs as $leg) {
$amount += $leg->getAmount();
$base += $leg->getBaseAmount();
}
if ($order instanceof AbstractModel) { // your vendor's order columns
$order->setData('acme_credit_amount', $amount)->setData('base_acme_credit_amount', $base);
$order->getResource()->saveAttribute($order, ['acme_credit_amount', 'base_acme_credit_amount']);
}
}
public function prepareRefundArguments(CreditmemoCreationArgumentsInterface $arguments, float $baseAmount): void
{
// Acme works from the unsaved memo: nothing to set on the arguments.
}
public function prepareRefundMemo(CreditmemoInterface $creditmemo, bool $toStoreCredit): void
{
$spent = (float) $creditmemo->getData('base_acme_credit_amount'); // what Acme's collector netted off
$credit = $toStoreCredit ? $spent + (float) $creditmemo->getBaseGrandTotal() : $spent;
if ($credit <= 0.0) {
if ($toStoreCredit) {
throw new LocalizedException(__('This refund has no amount to credit.'));
}
return; // a cash refund of a sale that spent no credit
}
// Acme's save-after observer credits the wallet for a flagged memo.
$creditmemo->setData('acme_refund_flag', 1)->setData('base_acme_credit_refunded', $credit);
}
public function creditedByVendor(CreditmemoInterface $saved, OrderInterface $order): VendorCreditOutcome
{
$entry = $this->wallet->findHistoryByMemo((string) $saved->getIncrementId()); // your vendor call
if ($entry !== null) {
return new VendorCreditOutcome(VendorCreditOutcome::CREDITED, (float) $entry->getBaseAmount());
}
if (!$saved->getData('acme_refund_flag')) {
return new VendorCreditOutcome(VendorCreditOutcome::NOT_CREDITED, 0.0, 'The memo never carried the refund flag.');
}
return new VendorCreditOutcome(VendorCreditOutcome::UNKNOWN, 0.0, 'No wallet entry names this memo.');
}
public function issueRefundToWallet(CreditmemoInterface $saved, OrderInterface $order): float
{
$legs = $this->readRefundLegs($saved);
$amount = $legs === [] ? 0.0 : $legs[0]->getBaseAmount();
if ($amount <= 0.0) {
throw new LocalizedException(__('This credit memo has no store credit to repair.'));
}
// your vendor call: Acme's own refund writer, so its order-side records move too
$this->wallet->refundToWallet((int) $order->getCustomerId(), $amount, 'Credit memo #' . $saved->getIncrementId());
return $amount;
}
public function refundsToStoreCreditByDefault(): bool
{
return $this->wallet->isRefundToCreditAutomatic(); // your vendor's own setting
}
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('A customer is required for store credit.');
}
if ($request->getAmount() <= 0.0) {
return $result->setSuccess(false)->setAmountApplied(0.0)->setErrorMessage('The refund amount must be greater than zero.');
}
$this->wallet->credit($customerId, $request->getAmount()); // your vendor call
return $result->setSuccess(true)->setAmountApplied($request->getAmount());
}
public function readRefundLegs(CreditmemoInterface $creditmemo): array
{
$base = round((float) $creditmemo->getData('base_acme_credit_refunded'), 2);
return $base <= 0.0 ? [] : [new CreditLeg(['kind' => CreditLegInterface::KIND_STORE_CREDIT,
'provider_code' => self::CODE, 'applies_as' => CreditLegInterface::APPLIES_AS_TENDER,
'direction' => CreditLegInterface::DIRECTION_RETURNED, 'base_amount' => $base, 'amount' => $base])];
}
public function readInvoiceLegs(InvoiceInterface $invoice): array
{
$base = round((float) $invoice->getData('base_acme_credit_amount'), 2);
return $base <= 0.0 ? [] : [new CreditLeg(['kind' => CreditLegInterface::KIND_STORE_CREDIT,
'provider_code' => self::CODE, 'applies_as' => CreditLegInterface::APPLIES_AS_TENDER,
'direction' => CreditLegInterface::DIRECTION_CONSUMED, 'base_amount' => $base,
'amount' => round((float) $invoice->getData('acme_credit_amount'), 2)])];
}
}
The example uses one currency for the base and the order amount on the refund leg, as a single-currency store has. A store whose order currency differs from its base currency reads both figures from the vendor's columns.
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 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 decreases by the applied amount, once; a re-sync of the same sale does not debit again.
- Apply more than the wallet holds through a second device that spent part of it first. The online sale answers the shortfall instead of overspending the wallet.
- Run a return and settle it to store credit. The wallet increases by the refund, the credit memo is saved, and the vendor's history names the memo.
- Refund a sale that spent store credit to its original tender. The credit the sale spent goes back to the wallet.
- Trigger the standalone "refund to credit" action on an order. The wallet increases and the cashier sees the result immediately.
Prove It
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;
use Hyva\Pos\TestFramework\Conformance\AbstractCreditProviderConformanceTest;
class StoreCreditProviderConformanceTest extends AbstractCreditProviderConformanceTest
{
protected function createProvider(): CreditProviderInterface
{
return new StoreCreditProvider(/* your vendor mocks */);
}
}
For a store-credit provider the kit asserts:
- the provider code and feature type, and that
refundCreditrejects a missing customer (with a message that mentions the customer) and non-positive amounts; - that
appliesAsanswerstenderevery time and matches the applicable answer'sapplies_as, and thatapplicableCreditanswers one of the three outcomes for its own kind and allows nothing unlesssupported(overridearrangeApplicable($provider)to give your doubles a balance); - that
prepareRefundArgumentsaccepts a refund amount, or refuses when you overridesupportsRefundToStoreCredit()to answer false; that the memo of a cash sale is left alone; thatcreditedByVendoranswers one of the three outcomes and onlycreditedcarries an amount; that the repair write returns a positive amount and propagates a vendor failure; and thatrefundsToStoreCreditByDefault()follows the vendor setting both ways (overridearrangeRefundToStoreCreditDefault($provider, $on)); - that the refund and invoice legs are of the provider's own kind, with the invoice legs
consumed; - that a funded debit answers
debitedfor the whole request, a short wallet online answersrefusedwithout throwing, and offline takes no more than the wallet holds.
Five more clauses run when you arrange them (each hook answers true once arranged and skips otherwise): the debit's arithmetic uses the locked row (arrangeLockedRowBelowEarlierRead($provider, $order): your vendor's plain read covers the request and the locked row does not, so the debit must refuse online); a debit of the read's own maximum, or of any multiple of its amount_step up to it, is never refused online (arrangeReadAndDebitAtTheMaximum($provider, $order)); and three clauses on the kit's carts (a 120.00 line with 20.83 tax, and a second line at 65.00): a line your vendor's product rule excludes is named ineligible with a reason and the maximum stays within the other line (arrangeOneIneligibleLine($provider, $sku)), a cart with no eligible line answers supported with 0, not redeemable and a reason (arrangeNoEligibleLine($provider)), and a credit that may not pay tax stops at 99.17 with a reason (arrangeNoTaxCoverage($provider)). Assertions are message-pattern based, so your wording stays yours and localizable.
Gotchas
- Never mutate the wallet in
applicableCredit. It runs on Apply, at checkout and again at order save. The one write isdebitWalletForOrder, and the core ledger makes it happen once per sale. - Lock before you read. A balance read before the lock, or a vendor model that reloads on save, lets two sales on one wallet both pass the check.
- Answer
refused, do not throw, for a short wallet. A throw is recorded asfailed; it is for a vendor write that failed. - Write the vendor's columns in
mapOrderTotals, at the debited figure. Your extension's invoice and credit memo collectors read the spent credit from the order's own columns. Without them, the invoice comes out higher than the order was paid. - Do not match history rows by amount in
creditedByVendor. Two equal partial refunds on one order satisfy the match. Use evidence written with the wallet write, such as a history row that names the memo or the core ledger row. - Crediting back a memo created outside the module is optional. A memo from the Magento admin or core REST is not passed to the provider. Returning what such an order spent is vendor-specific behavior, not part of this contract; the Amasty bridge implements it by reading the core debit ledgers.