Reward Points
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 reward-points bridge lets a customer spend loyalty points as tender at the register. The cashier applies points to the sale, POS splits the tender, and the points are debited from the customer's balance when the order saves. Your bridge translates two things to the vendor's points engine: the balance read and the debit. The points-to-currency conversion is yours - POS only ever speaks order currency, and your provider converts at the vendor's configured rate.
This page covers the reward-points contract and a worked implementation. Module scaffolding, registration guards, and installation live on Building a Bridge.
The Interfaces
A reward-points provider implements two interfaces from Hyva\Pos\Api:
interface CreditProviderInterface
{
/** @return \Hyva\Pos\Api\Data\CreditBalanceInterface[] */
public function getCustomerCredits(int $customerId): array;
public function checkGiftCard(string $code): ?CreditBalanceInterface;
public function applyCredit(CreditApplyRequestInterface $request): CreditApplyResultInterface;
public function refundCredit(CreditRefundRequestInterface $request): CreditApplyResultInterface;
public function getProviderCode(): string;
public function getFeatureType(): string;
}
interface RewardPointsDebitProviderInterface
{
/** @throws \Magento\Framework\Exception\LocalizedException */
public function debitRewardPointsForOrder(OrderInterface $order, float $amount): void;
}
| Method | What your provider does |
|---|---|
getCustomerCredits |
Return the points balance as a single CreditBalance entry. Set balance to the raw points figure and, when the vendor has a conversion rate, currency_value (what the points are worth) and points_per_currency (the rate). Return [] for a zero balance. |
checkGiftCard |
Return null. Points are not code-based. |
applyCredit |
Validate only. Check the live balance, reject non-positive amounts, clamp to what the balance covers, and report the amount via getAmountApplied() - POS treats it as authoritative for the tender split. Never mutate the wallet here. |
refundCredit |
Reject missing customers and non-positive amounts. No POS flow reaches this method for reward points today (see Out of Contract), so a clear failure message is a valid implementation. |
getProviderCode |
A unique identifier, for example acme_reward_points. |
getFeatureType |
Return reward_points. This is what routes reward traffic to your provider. |
debitRewardPointsForOrder |
The real wallet write. Convert $amount (order currency) to points at the vendor rate and deduct them. Throw LocalizedException on any failure. |
Contract Rules
applyCreditis validate-only. The wallet write happens once, at order save, through the core dispatcher. A provider that deducts points insideapplyCreditdouble-debits.- The core owns the debit dispatch. The core
DispatchRewardPointsDebitPlugincallsdebitRewardPointsForOrderinside the order-save flow whenever a new order carriesextension_attributes.pos_reward_points_amount > 0and apos_sale_id. There is exactly one redemption leg per order (unlike gift cards, where a sale can redeem several codes). Never register your ownOrderRepositoryInterfaceplugin to debit at checkout. - The core owns idempotency. The dispatcher records one debit per
pos_sale_idin the corehyva_pos_reward_debit_ledgertable and skips a repeat call for a sale it has already debited - so an offline queue draining twice cannot double-debit. Your bridge must not keep its own idempotency records, and it never parsespos_reward_points_amountitself. - Amount is order currency; conversion is yours. Rates differ per vendor and per store view, so the core never speaks points. Convert inside
debitRewardPointsForOrder. - Failure never aborts the order. Throw
LocalizedExceptionon failure - insufficient points, rate not configured. The core dispatcher logs it and lets the order save proceed: 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. Insufficient points must throw - never swallow the failure and lose the redemption. - Registered customers only. The dispatcher skips guest orders (warn-logged). Points need an account to debit.
- No
instanceofself-guards. The dispatcher only invokes the active reward-points provider, so co-installation with other bridges is safe structurally.
Build It
1. Implement the Provider
The skeleton below follows the shipped Mirasvit bridge. Swap the PointsWalletInterface calls for your vendor's balance and transaction APIs.
<?php
declare(strict_types=1);
namespace Acme\PosBridge\Model;
use Acme\Loyalty\Api\PointsWalletInterface;
use Hyva\Pos\Api\CreditProviderInterface;
use Hyva\Pos\Api\Data\CreditApplyRequestInterface;
use Hyva\Pos\Api\Data\CreditApplyResultInterface;
use Hyva\Pos\Api\Data\CreditBalanceInterface;
use Hyva\Pos\Api\Data\CreditRefundRequestInterface;
use Hyva\Pos\Api\RewardPointsDebitProviderInterface;
use Hyva\Pos\Model\Data\CreditApplyResult;
use Hyva\Pos\Model\Data\CreditBalance;
use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\Data\OrderInterface;
class RewardPointsProvider implements CreditProviderInterface, RewardPointsDebitProviderInterface
{
public function __construct(private readonly PointsWalletInterface $wallet)
{
}
public function getProviderCode(): string
{
return 'acme_reward_points';
}
public function getFeatureType(): string
{
return 'reward_points';
}
public function getCustomerCredits(int $customerId): array
{
$points = $this->wallet->getBalance($customerId);
if ($points <= 0) {
return [];
}
$rate = $this->wallet->getPointsPerCurrencyUnit(); // e.g. 10.0 points = 1.00
return [new CreditBalance([
'type' => 'reward_points',
'provider' => 'acme_reward_points',
'label' => 'Reward Points',
'balance' => (float) $points,
'is_redeemable' => $rate > 0.0,
'currency_value' => $rate > 0.0 ? $points / $rate : null,
'points_per_currency' => $rate > 0.0 ? $rate : null,
])];
}
public function checkGiftCard(string $code): ?CreditBalanceInterface
{
return null; // points are not code-based
}
public function applyCredit(CreditApplyRequestInterface $request): CreditApplyResultInterface
{
$result = new CreditApplyResult();
$customerId = $request->getCustomerId();
if ($customerId === null || $request->getAmount() <= 0.0) {
return $result->setSuccess(false)->setAmountApplied(0.0)
->setErrorMessage('A registered customer and a positive amount are required.');
}
$rate = $this->wallet->getPointsPerCurrencyUnit();
$points = $this->wallet->getBalance($customerId);
$pointsNeeded = (int) ceil($request->getAmount() * $rate);
if ($rate <= 0.0 || $pointsNeeded > $points) {
return $result->setSuccess(false)->setAmountApplied(0.0)
->setRemainingBalance((float) $points)
->setErrorMessage('Insufficient reward points balance.');
}
// Validate only: report what the debit WILL do, mutate nothing.
return $result->setSuccess(true)
->setAmountApplied(round($pointsNeeded / $rate, 2))
->setRemainingBalance((float) ($points - $pointsNeeded));
}
public function debitRewardPointsForOrder(OrderInterface $order, float $amount): void
{
$customerId = (int) $order->getCustomerId();
$rate = $this->wallet->getPointsPerCurrencyUnit();
if ($rate <= 0.0) {
throw new LocalizedException(__('Reward points exchange rate not configured; points not deducted.'));
}
$pointsToDeduct = (int) ceil($amount * $rate);
if ($pointsToDeduct > $this->wallet->getBalance($customerId)) {
throw new LocalizedException(__('Insufficient reward points balance; points not deducted.'));
}
$this->wallet->deduct($customerId, $pointsToDeduct, sprintf('POS order %s', $order->getIncrementId()));
}
public function refundCredit(CreditRefundRequestInterface $request): CreditApplyResultInterface
{
// No POS flow triggers this for reward points today; fail with an
// actionable message. Error messages reach the cashier verbatim.
return (new CreditApplyResult())->setSuccess(false)->setAmountApplied(0.0)
->setErrorMessage('Refunding to reward points is not supported at the register.');
}
}
The shipped Mirasvit bridge implements refundCredit as a real points add-back instead; both shapes are valid as long as missing customers and non-positive amounts are rejected.
2. Register It in the Pool
<type name="Hyva\Pos\Model\CreditProviderPool">
<arguments>
<argument name="providers" xsi:type="array">
<item name="acme_reward_points" xsi:type="string">Acme\PosBridge\Model\RewardPointsProvider</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.
3. Activate It
Set your provider as the active reward-points provider under Stores → Configuration → Hyvä POS → Advanced → Credit & Return Providers. When nothing is configured, the pool auto-detects the first installed provider for the feature.
4. Verify at the Register
- Open a sale for a registered customer who has points. The balance must appear as a tender option with its currency equivalent.
- Apply points and complete the sale. Confirm in the vendor's admin that exactly the converted points were deducted, once.
- Re-sync the same sale (for example, let the offline queue drain it again). The balance must not change - the core ledger blocks the second debit.
- Try to apply more than the balance covers. The cashier must see your error message, and the sale must still complete with other tender.
Prove It
Subclass the credit conformance kit and supply your provider wired to vendor mocks:
class RewardPointsProviderConformanceTest extends AbstractCreditProviderConformanceTest
{
protected function createProvider(): CreditProviderInterface
{
return new RewardPointsProvider(/* your vendor mocks */);
}
}
The kit always asserts the provider code and feature type are valid and that refundCredit rejects a missing customer and non-positive amounts. The two reward-debit scenarios run only when the provider implements RewardPointsDebitProviderInterface (they skip otherwise): a debit against a funded balance calls through without throwing, and an underfunded balance throws LocalizedException.
Arrange them through the hooks arrangeRewardPointsDebitSucceeds() and arrangeRewardPointsDebitInsufficientFunds(). Both default to no-ops: override the first to make your vendor mocks report a funded balance; the second's default usually suffices, because a mocked balance read that returns zero is already insufficient. Override debitAmount() or makeDebitOrder() only if your provider reads order fields the generic double does not expose. Assertions are message-pattern based, so your cashier-facing wording stays yours.
Out of Contract
Two reward-refund behaviors are deliberately not part of the contract:
- Spent-points return - giving back the points a refunded order redeemed. This only becomes relevant once a points-paid credit memo flow exists to trigger it. No such flow exists in POS today, so it is not built.
- Earned-points revocation - clawing back points a now-refunded purchase earned. This is net-new product behavior with unsettled policy questions (proportionality, the negative-balance floor, points already spent), and Adobe Commerce has no native clawback. It needs a product decision before any bridge work.
Concretely: store credit is the only tender the POS return wizard can refund to, and POS credit memos never carry a reward-points leg. That means there is no trigger for a reward-points equivalent of the store-credit MemoCreditIssuerInterface hook - if you build one today, it is dead code. Implement the two interfaces above and stop there.