Skip to content

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 at the register. The cashier applies points to the sale, and the points are debited from the customer's balance when the order saves. Depending on the vendor, points apply as a tender (they pay part of the total after tax) or as a discount (they lower the price before tax). Your bridge translates three things to the vendor's points engine: what a cart may spend, the debit, and the points on invoices and credit memos. The points-to-currency conversion is yours; POS only speaks order currency, and your provider converts at the vendor's configured rate.

This page covers what is specific to reward points. The CreditProviderInterface methods every credit provider implements are described on Store Credit. Module scaffolding, registration guards, and installation live on Building a Bridge.

The Interfaces

Interface Required Purpose
Hyva\Pos\Api\CreditProviderInterface Yes What a cart may spend, how the points apply, the points legs on documents
Hyva\Pos\Api\RewardPointsDebitProviderInterface Yes Debit the points on order save, and write the vendor's record of them
Hyva\Pos\Api\RewardPointsEarnedReaderInterface No What the vendor credited for an order
Hyva\Pos\Api\RewardPointsEarnProviderInterface No Earn for a guest sale whose receipt a customer claimed
namespace Hyva\Pos\Api;

use Hyva\Pos\Api\Data\CreditDebitResultInterface;
use Hyva\Pos\Api\Data\PointsEarnedInterface;
use Magento\Sales\Api\Data\OrderInterface;

interface RewardPointsDebitProviderInterface
{
    /** @throws \Magento\Framework\Exception\LocalizedException when the vendor write fails */
    public function debitRewardPointsForOrder(OrderInterface $order, float $amount, bool $partialAllowed): CreditDebitResultInterface;

    /** @param \Hyva\Pos\Api\Data\CreditLegInterface[] $legs */
    public function mapOrderTotals(OrderInterface $order, array $legs): void;
}

interface RewardPointsEarnedReaderInterface
{
    public function pointsEarnedForOrder(OrderInterface $order): PointsEarnedInterface;
}

interface RewardPointsEarnProviderInterface
{
    /** @throws \Magento\Framework\Exception\LocalizedException when the vendor refuses; the claim logs and stands */
    public function earnPointsForOrder(OrderInterface $order): void;
}

The reward-points specifics of CreditProviderInterface:

  • getFeatureType() returns reward_points, and checkGiftCard() returns null.
  • getCustomerCredits() returns the points balance as a single CreditBalance row: balance is 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.
  • refundCredit() rejects a missing customer and non-positive amounts. No POS flow refunds to points (see Out of Contract), so a clear failure message is a valid implementation; a real points add-back is valid too.

How the Points Apply

appliesAs(string $kind) answers discount when your vendor applies points before tax (Amasty) and tender when they pay part of the total after tax (Adobe Commerce). It is a static classification. The core uses it when a read or a debit failed, so a points discount whose debit failed stays a discount and the order keeps it as issued rather than owing it. When the store names a points provider that is not installed, nothing can classify the points the sale spent, so the core stops the order save and the register keeps the sale queued.

A Points Discount

The register sends a points discount in the order's own discount columns, Magento's way for a discount on prices including tax: discount_amount with its tax compensation, spread over the lines with its cent shares (the remainder on the last line it may pay for), the description your vendor writes ("Used N reward points" on Amasty), pos_reward_points_amount (the discount as issued) with pos_reward_points (the points behind it), and each line's share on the item's extension_attributes.pos_reward_points_amount. It sets no applied_rule_ids for the points. The line shares are what mark the points as a discount: an order whose lines carry none tendered them, whatever else its discount holds. The core then:

  • settles the points before store credit and gift cards, which pay the price after the discount and are capped at it;
  • asks your applicableCredit on the cart before the discount (the lines' and the order's discount without the points' own shares), so you price the cart the register asked about;
  • records each line's share as issued in sales_order_item.pos_reward_points_amount, whatever the wallet gave, before mapOrderTotals, so a vendor with a line column of its own maps it from there;
  • keeps pos_reward_points as the register sent it, or sets it to the points your result says the request came to.

When the wallet gives fewer points than asked on a discount, the core leaves price and tax as issued and the merchant absorbs the value of the missing points; the order records the points asked for, consumed and short in pos_reward_points_requested, pos_reward_points_consumed and pos_reward_points_shortfall. For a tender the shortfall becomes an amount due, as for store credit.

How an Order Booked Its Points

Read how an order booked its points from what the booking recorded, never from the order's totals, through Hyva\Pos\Model\CreditDebit\PointsBookingReader::bookedAs($order):

The order's records Booked as
No pos_credit_outcomes (an order booked by an earlier module) tender
The points leg in pos_credit_outcomes recorded applies_as tender tender
The leg recorded discount, and the lines carry their share in sales_order_item.pos_reward_points_amount discount
The leg recorded discount, and no line carries a share tender
Outcomes that do not decode, no points leg, or no classification on it null: logged, and the caller leaves the points leg untouched

Document collectors, the legs a bridge reads back from documents and the layaway cancellation fee follow this answer.

What a Cart May Spend

applicableCredit() follows the shared contract on Store Credit. For points:

  • Answer the minimum and the redemption limit your vendor applies. A customer below a minimum points balance is a rule you answered: supported with max_amount 0, max_points 0, min_points set and your vendor's sentence as the reason. Keep unsupported for a kind you do not answer at all.
  • Report points_rate (points per one unit of the currency), points_granularity (whole or fractional), round_rule (up or down, the vendor's rounding of the points behind an amount) and applies_as.
  • Round the maximum the way your debit converts it back. A debit of the read's own max_amount must be taken online. A maximum rounded up to the cent can ask for more points than the customer holds: at 10 points per 1.00, 1234.56 points are worth 123.456, offered as 123.46, converted back to 1234.60 points and refused. Round down to the cent, or to the rate's step when your vendor redeems in steps.
  • Name the step in amount_step. points_rate and points_granularity cannot carry it: at 100 points = 5.00 the rate is 20 per 1.00 and the points are whole, yet only multiples of 5.00 are taken. The register applies amounts in multiples of the step, and the core rounds its still-owed cap down to a multiple of it.

What the reference bridges answer. Amasty: Minimum Points Requirement, Apply Redemption Limit (a fixed number of points, or a percentage of the eligible lines' row totals), Spend Points on Specific Products re-implemented on each line's SKU (an excluded line carries "Reward points on this store cannot be spent on this product."), and the eligible lines' price as Amasty's discount prices them; the maximum is rounded down to the cent, amount_step 0.01. Adobe Commerce: the points' value gated on Rewards Points Balance Redemption Threshold, in whole steps of its rate, amount_step the rate's currency amount.

The Points Debit

debitRewardPointsForOrder(OrderInterface $order, float $amount, bool $partialAllowed) converts $amount (order currency) to points at your rate and deducts them. The core drives it for a new order that carries extension_attributes.pos_reward_points_amount > 0 and a pos_sale_id, inside the order-save transaction, after asking applicableCredit with the order's figures. There is one redemption leg per order.

The sequence, the meaning of $partialAllowed, the row lock and the savepoint are the same as for store credit; see The Checkout Debit. For points:

  • An amount that is not a multiple of the applicable answer's amount_step is refused online, naming the step, and asked at the step's multiple below it offline.
  • Lock the customer's points row in the vendor's own table with RowLock::lock() and read the balance from it.
  • The result reports requested ($amount), debited (the currency value of the points taken), points_requested, points_debited, applies_as, and points_granularity and amount_step as your vendor takes them now; the core fills the last two from your applicable answer when you leave them out.
  • Value what you take. debited is the value of the points taken, never more and never points without value. A vendor that redeems in steps of its rate takes whole steps, rounded down: at 100 points = 1.00, a request of 7.50 takes 700 points and books 7.00 (refused online). A request within a cent's rounding of the balance's value takes the balance.
  • Insufficient points is an outcome. Answer refused, or partial with $partialAllowed; throw only when the vendor write fails. A thrown failure is rolled back to the leg's savepoint and recorded as failed: nothing taken, no ledger row, no points booked. Nothing retries it later.
  • Registered customers only. The dispatcher refuses points on a guest order before it reaches you.

Idempotency is per pos_sale_id in the core hyva_pos_reward_debit_ledger. The ledger records how the leg applied and the points asked and taken, and a leg answered from it keeps them: mapOrderTotals gets the points consumed and a discount stays a discount. Your bridge never parses pos_reward_points_amount to decide what to debit and keeps no ledger of its own.

mapOrderTotals: The Points the Order Spent

mapOrderTotals(OrderInterface $order, array $legs) writes the vendor's own record of the points the order spent, at the points and value debited. It is called once, after the debit, inside the same transaction, and never when nothing was debited. Persist with the order resource's saveAttribute(), never with a repository save of the order.

Since module 1.3.0 this record is written here, at the debited figure. A bridge built for an earlier module that mapped pos_reward_points_amount onto the vendor's columns from a beforeSave plugin on OrderRepositoryInterface removes that plugin.

The reference bridges write: Adobe Commerce reward_currency_amount, its base, and reward_points_balance (the points taken), which its invoice collector values and its memo collector caps at; Amasty am_spent_reward_points on the order and on each line (the line's share at the rate scaled to the points consumed, the remainder on the last line). For the lock, Amasty locks amasty_rewards_customer_balance by customer and sets its repository's cached balance to the locked figure; Commerce locks magento_reward by customer and website and writes the delta on the reward model it set the locked row on.

Collectors for a Discount Vendor

Magento's own invoice and memo discount collectors take a points discount off every document, so a discount bridge's document collectors move no total: they write the points leg. A vendor that applies points as a discount and has no invoice collector of its own (Amasty) gets two collectors from its bridge, for POS orders only (pos_sale_id set). The Amasty bridge's Model\Total\Invoice\PosRewardPoints and Model\Total\Creditmemo\PosRewardPoints are the reference. They:

  • read how the order booked its points from PointsBookingReader: a tender order keeps the tender figures, and an order whose records do not say gets no leg;
  • per line, take the money as the line's discount on the document in the proportion the order line's discount is points, and the points as the line's points by quantity; the document that completes the order takes what the earlier ones left, and a repeated collection replaces the document's own share;
  • keep the money apart from the points: the money is as issued, the points as consumed, so neither is derived from the other;
  • on a memo, write each line's figures on its memo item under RefundPreviewLineInterface::ITEM_POINTS_AMOUNT and ITEM_POINTS, which the refund preview serves per line, and give back no more points than were consumed less what came back already.

The Points Legs on Documents

readRefundLegs() and readInvoiceLegs() answer the points legs as described on Store Credit, with points and points_granularity set. A discount leg is no part of the handback, because the memo's refund is already after the discount. Adobe Commerce reads reward_currency_amount and reward_points_balance (whole points, a tender); Amasty reads the bridge's own memo columns hyva_pos_base_reward_points_amount and hyva_pos_reward_points (a discount), which its collector writes.

Points Earned

RewardPointsEarnedReaderInterface::pointsEarnedForOrder(OrderInterface $order) answers a PointsEarnedInterface from your vendor's own record of the order's earning: earned with the points it credited (0 when it ran and earned nothing) and the points voided since, or pending with 0 before it ran. It reads only and never earns. The core serves it as GET /V1/pos/orders/:orderId/points-earned, so the register shows what Magento credited rather than an estimate, and answers unsupported when your provider does not implement it or throws. Amasty: am_earn_reward_points on the order once it completed, am_deduct_reward_points voided. Adobe Commerce: the purchase-earning history rows whose additional data names the order's increment id, written once the order is paid in full.

RewardPointsEarnProviderInterface::earnPointsForOrder(OrderInterface $order) earns for an order after the fact. A guest sale earns nothing at placement; when a customer claims the receipt and the order is assigned to their account, the claim asks the active provider to earn for it the way the vendor does for a logged-in sale. The order now carries a customer id. Throw LocalizedException when the vendor refuses; the claim logs it and stands. The claim saves the earning as owed in the same save as the claim (hyva_pos_receipt.points_earn_pending_at): a completed earning clears it, a failure counts an attempt (points_earn_attempts). The hourly cron hyva_pos_earn_points_for_claimed_receipts runs the earning again for receipts still owed after ten minutes, up to 24 attempts; after that the receipt is left for an operator and logged at error with the order number. Your earnPointsForOrder can therefore run again for an order after a failed attempt.

Contract Rules

  • applicableCredit never deducts points. The wallet write happens once, at order save, through the core dispatcher.
  • Amount is order currency; conversion is yours. Rates differ per vendor and per store view, so the core never converts. Convert inside debitRewardPointsForOrder and report both figures on the result.
  • The core owns the debit dispatch and idempotency. Never register your own OrderRepositoryInterface plugin to debit at checkout.
  • No instanceof self-guards. The dispatcher only invokes the active reward-points provider.

Build It

1. Implement the Provider

The skeleton below is a tender-style vendor with a rate of whole points per currency unit. It shows the points-specific methods; readRefundLegs() and readInvoiceLegs() follow the store-credit example with KIND_REWARD_POINTS and points set.

src/Model/RewardPointsProvider.php
<?php
declare(strict_types=1);

namespace Acme\PosBridge\Model;

use Acme\Loyalty\Api\PointsWalletInterface;
use Hyva\Pos\Api\{CreditProviderInterface, RewardPointsDebitProviderInterface};
use Hyva\Pos\Api\Data\{CreditApplicableInterface, CreditApplicableRequestInterface, CreditApplyResultInterface,
    CreditBalanceInterface, CreditDebitResultInterface, CreditLegInterface, CreditRefundRequestInterface};
use Hyva\Pos\Model\CreditDebit\RowLock;
use Hyva\Pos\Model\Data\{CreditApplicable, CreditApplyResult, CreditBalance, CreditDebitResult, CreditLineEligibility};
use Magento\Sales\Api\Data\{CreditmemoInterface, InvoiceInterface, OrderInterface};

class RewardPointsProvider implements CreditProviderInterface, RewardPointsDebitProviderInterface
{
    private const CODE = 'acme_reward_points';

    public function __construct(
        private readonly PointsWalletInterface $wallet, // your vendor service
        private readonly RowLock $rowLock
    ) {
    }

    public function getProviderCode(): string { return self::CODE; }
    public function getFeatureType(): string { return CreditLegInterface::KIND_REWARD_POINTS; }
    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
    {
        $points = $this->wallet->getBalance($customerId); // your vendor call
        $rate = $this->wallet->getPointsPerCurrencyUnit(); // e.g. 10.0 points = 1.00
        return $points <= 0 ? [] : [new CreditBalance(['type' => 'reward_points', 'provider' => self::CODE,
            '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 applicableCredit(CreditApplicableRequestInterface $request): CreditApplicableInterface
    {
        $rate = $this->wallet->getPointsPerCurrencyUnit();
        $points = $request->getCustomerId() === null ? 0.0 : (float) $this->wallet->getBalance($request->getCustomerId());
        $max = $rate > 0.0 ? floor($points / $rate * 100) / 100 : 0.0; // rounded down, so the debit of it is taken
        $lines = [];
        foreach ($request->getLines() as $line) {
            $lines[] = (new CreditLineEligibility())->setSku($line->getSku())->setIsEligible(true);
        }
        return (new CreditApplicable())->setOutcome(CreditApplicableInterface::OUTCOME_SUPPORTED)
            ->setCreditType(CreditLegInterface::KIND_REWARD_POINTS)->setProviderCode(self::CODE)
            ->setAppliesAs(CreditLegInterface::APPLIES_AS_TENDER)->setBalance($points)
            ->setMaxAmount($max)->setMinAmount(0.0)->setIsRedeemable($max > 0.0)->setLines($lines)
            ->setPointsRate($rate)->setPointsGranularity(CreditApplicableInterface::GRANULARITY_WHOLE)
            ->setMaxPoints(floor($max * $rate))->setAmountStep($rate > 0.0 ? max(0.01, 1 / $rate) : null);
    }

    public function debitRewardPointsForOrder(OrderInterface $order, float $amount, bool $partialAllowed): CreditDebitResultInterface
    {
        $rate = $this->wallet->getPointsPerCurrencyUnit();
        $customerId = (int) $order->getCustomerId();
        $row = $this->rowLock->lock('acme_points_balance', ['customer_id = ?' => $customerId]);
        $held = (float) ($row['points'] ?? 0.0);
        $asked = floor($amount * $rate + 0.0001);
        $take = $asked <= $held ? $asked : ($partialAllowed ? floor($held) : 0.0);
        $result = (new CreditDebitResult())->setKind(CreditLegInterface::KIND_REWARD_POINTS)->setProviderCode(self::CODE)
            ->setAppliesAs(CreditLegInterface::APPLIES_AS_TENDER)->setRequested($amount)->setPointsRequested($asked)
            ->setPointsGranularity(CreditApplicableInterface::GRANULARITY_WHOLE);
        if ($rate <= 0.0 || $take <= 0.0) {
            return $result->setDebited(0.0)->setPointsDebited(0.0)
                ->setOutcome(CreditDebitResultInterface::OUTCOME_REFUSED)->setReason('Insufficient reward points balance.');
        }
        // your vendor call: write the locked balance less $take; throws LocalizedException on failure
        $this->wallet->writeBalance($customerId, $held - $take, 'POS order ' . $order->getIncrementId());
        return $result->setDebited(round($take / $rate, 2))->setPointsDebited($take)->setOutcome(
            $take < $asked ? CreditDebitResultInterface::OUTCOME_PARTIAL : CreditDebitResultInterface::OUTCOME_DEBITED
        );
    }

    public function mapOrderTotals(OrderInterface $order, array $legs): void
    {
        // your vendor's order columns at the points and value debited, written with saveAttribute()
    }

    public function refundCredit(CreditRefundRequestInterface $request): CreditApplyResultInterface
    {
        return (new CreditApplyResult())->setSuccess(false)->setAmountApplied(0.0)
            ->setErrorMessage('Refunding to reward points is not supported at the register.');
    }

    public function readRefundLegs(CreditmemoInterface $creditmemo): array { /* as on Store Credit */ return []; }
    public function readInvoiceLegs(InvoiceInterface $invoice): array { /* as on Store Credit */ return []; }
}

2. Register It in the Pool

src/etc/di.xml
<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

  1. Open a sale for a registered customer who has points. The balance appears with its currency equivalent.
  2. Apply points and complete the sale. Confirm in the vendor's admin that the converted points were deducted once.
  3. Re-sync the same sale (for example, let the offline queue drain it again). The balance does not change.
  4. Try to apply an amount between two of your rate's steps. The register offers multiples of amount_step only.
  5. Invoice and refund the order. The points legs appear on the invoice and in the refund preview.

Prove It

Subclass the credit conformance kit and supply your provider wired to vendor mocks:

tests/unit/RewardPointsProviderConformanceTest.php
class RewardPointsProviderConformanceTest extends AbstractCreditProviderConformanceTest
{
    protected function createProvider(): CreditProviderInterface
    {
        return new RewardPointsProvider(/* your vendor mocks */);
    }
}

The kit asserts that the provider code and feature type are valid, that refundCredit rejects a missing customer and non-positive amounts, that appliesAs answers the same every time and matches the applicable answer's applies_as, and that applicableCredit answers one of the three outcomes and allows nothing unless supported. With RewardPointsDebitProviderInterface implemented it asserts that a funded debit answers debited for the whole request and reports the points taken (arrangeRewardPointsDebitSucceeds()), that a funded debit names its granularity and its step, and that a short balance online answers refused without throwing (arrangeRewardPointsDebitInsufficientFunds()). With RewardPointsEarnedReaderInterface implemented, the earned read answers earned, pending or unsupported and no points unless earned (arrangePointsEarned($provider, $order)).

Clauses with a hook that answers true once arranged: the debit's arithmetic uses the locked row (arrangeLockedRowBelowEarlierRead), and a debit of the read's own maximum, or of any multiple of its amount_step up to it, is never refused online (arrangeReadAndDebitAtTheMaximum, with a balance whose value rounds, such as 1134.56 points at 10 per 1.00, or one between your rate's steps). A vendor with a product rule arranges the two line clauses on the kit's two-line cart (arrangeOneIneligibleLine($provider, $sku), arrangeNoEligibleLine($provider)). Assertions are message-pattern based, so your cashier-facing wording stays yours.

Out of Contract

Store credit is the only tender the POS return wizard can refund to, so there is no trigger for a reward-points equivalent of the store-credit MemoCreditIssuerInterface hook. The points a refund gives back are a leg the reader serves, not an issuance. Two reward-refund behaviors are vendor-specific and not part of the contract:

  • Spent-points return, giving back the points a refunded order redeemed. The Amasty bridge returns the memo's points leg on credit memo save and raises Amasty's own am_refund_reward_points. The Adobe Commerce bridge sets reward_points_balance_refund_flag and the memo's points as reward_points_balance_refund on a register order's memo before the refund operation, so Commerce returns them itself and counts them refunded.
  • Earned-points revocation, clawing back points a now-refunded purchase earned. It is opt-in through Stores → Configuration → Hyvä POS → Advanced → Credit & Return Providers → Revoke Earned Points on POS Refund (hyva_pos_advanced/providers/revoke_earned_points_on_refund, off by default). A bridge that supports it reads the setting and drives its vendor's own mechanics, proportional to the refunded share; a deduction entered manually on the admin credit memo always wins. The Amasty bridge implements it; Adobe Commerce has no native clawback, so its bridge ignores the setting.

Gotchas

  • Write the vendor's columns in mapOrderTotals. Adobe Commerce reads reward_currency_amount and its base off the order for its invoice and memo collectors; Amasty reads the points from am_spent_reward_points for its order view, its memo netting and its cancellation return. Without them the documents come out wrong.
  • A discount vendor needs collectors of its own for POS orders. If your vendor applies points as a discount and has no invoice collector for points, ship the two collectors described above.
  • Read the booking, not the totals. Use PointsBookingReader::bookedAs() to decide whether an order's points were a tender or a discount; a coupon larger than the points does not make an order a points discount.
  • Round the maximum down. A maximum rounded up can ask for more points than the customer holds, and the online debit of it is refused.