Skip to content

Gift Cards

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 gift-card bridge lets the cashier redeem one or more gift-card codes as tender on a POS sale: look a code up, apply (part of) its balance against the total, and charge the card when the order saves. Unlike store credit - one wallet per customer - a single POS order can carry several cards, each with its own code, amount, and idempotency identity. The contract is therefore per code: the core calls your debit method once for every redeemed code, with the exact (code, amount) pair, and guarantees each pair is charged at most once per sale.

This page covers only what is specific to gift cards. Module scaffolding, the registration guard, and the pool architecture are on Building a Bridge.

The Interfaces

A gift-card provider implements two interfaces from Hyva\Pos\Api. Both are required.

interface CreditProviderInterface
{
    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;
}
  • getCustomerCredits(int $customerId): array - return []. Gift cards are code-based bearer instruments, not customer-linked balances; the app looks them up by code via checkGiftCard().
  • checkGiftCard(string $code): ?CreditBalanceInterface - look the code up in your vendor module and return a CreditBalance (type gift_card, your provider code, the live balance, currency, the code, is_redeemable, optionally expires_at). Return null - never throw - for an unknown, empty, or inactive card.
  • applyCredit(...) - validate one applied code. Check the live balance, clamp the requested amount to it, and report the amount actually deducted via setAmountApplied() - the POS client treats that figure as authoritative for the tender split. Do not charge the card here.
  • refundCredit(...) - not the refund path for gift cards (see Gotchas). Return an actionable failure result telling the cashier which flow to use instead.
  • getProviderCode(): string - your stable identifier, for example acme_giftcard.
  • getFeatureType(): string - return gift_card.
interface GiftCardDebitProviderInterface
{
    /**
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function debitGiftCardForOrder(OrderInterface $order, string $code, float $amount): void;
}
  • debitGiftCardForOrder(...) - the actual charge, per card. The core DispatchGiftCardDebitPlugin calls it once per redeemed code inside the order-save flow, after the order row exists, whenever a new order carries extension_attributes.pos_gift_card_redemptions and a pos_sale_id. You receive one resolved (code, amount) pair per call, with $amount in the order's currency. Idempotency is already guaranteed before your code runs: the core records one debit per (pos_sale_id, code) in its hyva_pos_giftcard_debit_ledger table and skips any leg it has already debited. Throw a LocalizedException on failure (unknown code, insufficient balance).

Contract Rules

  • applyCredit is validate-only. The card is charged exactly once, by debitGiftCardForOrder on order save. A provider that mutates the card inside applyCredit double-debits. An old POS build that still drives the post-place apply loop against your module gets a harmless validation no-op.
  • The core owns orchestration and idempotency. The dispatcher parses the pos_gift_card_redemptions extension attribute (a JSON array of {code, amount}) and keeps the hyva_pos_giftcard_debit_ledger. Your bridge never parses the redemptions itself, never keeps its own idempotency records for this flow, and never registers its own OrderRepositoryInterface plugin to debit at checkout.
  • A debit failure never loses the order. Throw a LocalizedException from debitGiftCardForOrder; the core dispatcher logs it, leaves that card's ledger row unwritten (so a later re-save retries just that leg), and continues to the next code. One bad card never aborts the order and never blocks the sibling cards. Do not return a soft no-op instead of throwing - the unwritten ledger row is what makes the retry possible.
  • Guests may pay by gift card. A gift card is a bearer instrument, so the dispatcher does not require a registered customer; the ledger row's customer_id is NULL for guest orders. Do not add a customer requirement of your own.
  • No vendor-specific values reach the app. Report balances through the CreditBalance shape, and keep error messages set via setErrorMessage() human-readable - they are shown to the cashier verbatim, so never echo raw exception text.
  • No instanceof guards against sibling bridges. The pool only ever invokes the active gift-card provider; co-install safety is structural.

Build It

  1. Scaffold the module, guard registration.php on a real vendor interface, and declare the load order as described in Building a Bridge.

  2. Implement the provider. This skeleton follows the shipped Aheadworks and Amasty bridges:

    src/Model/GiftCardProvider.php
    <?php
    declare(strict_types=1);
    
    namespace Acme\PosBridge\Model;
    
    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\GiftCardDebitProviderInterface;
    use Hyva\Pos\Model\Data\CreditApplyResult;
    use Hyva\Pos\Model\Data\CreditBalance;
    use Magento\Framework\Exception\LocalizedException;
    use Magento\Sales\Api\Data\OrderInterface;
    
    class GiftCardProvider implements CreditProviderInterface, GiftCardDebitProviderInterface
    {
        public function getProviderCode(): string
        {
            return 'acme_giftcard';
        }
    
        public function getFeatureType(): string
        {
            return 'gift_card';
        }
    
        public function getCustomerCredits(int $customerId): array
        {
            return []; // code-based: the app looks cards up via checkGiftCard()
        }
    
        public function checkGiftCard(string $code): ?CreditBalanceInterface
        {
            $balance = 0.0; // your vendor call: load the card by $code, read its balance
            if ($balance <= 0.0) {
                return null; // unknown, empty, or inactive: null, never throw
            }
            return new CreditBalance([
                'type' => 'gift_card',
                'provider' => $this->getProviderCode(),
                'label' => 'Gift Card',
                'balance' => $balance,
                'currency' => 'EUR', // your vendor call / store base currency
                'code' => $code,
                'is_redeemable' => true,
            ]);
        }
    
        public function applyCredit(CreditApplyRequestInterface $request): CreditApplyResultInterface
        {
            $balance = 0.0; // your vendor call: live balance for $request->getCode()
            $amount = min($request->getAmount(), $balance);
            if ($amount <= 0.0) {
                return (new CreditApplyResult())->setSuccess(false)->setAmountApplied(0.0)
                    ->setRemainingBalance($balance)
                    ->setErrorMessage('Insufficient gift card balance');
            }
            // Validate only - the core dispatcher charges the card on order save.
            return (new CreditApplyResult())->setSuccess(true)
                ->setAmountApplied($amount)->setRemainingBalance($balance - $amount);
        }
    
        public function refundCredit(CreditRefundRequestInterface $request): CreditApplyResultInterface
        {
            return (new CreditApplyResult())->setSuccess(false)->setAmountApplied(0.0)
                ->setRemainingBalance(0.0)
                ->setErrorMessage('Use the native creditmemo flow to credit the gift card back.');
        }
    
        public function debitGiftCardForOrder(OrderInterface $order, string $code, float $amount): void
        {
            $balance = 0.0; // your vendor call: load the card by $code
            if ($balance + 0.0001 < $amount) {
                throw new LocalizedException(__('Insufficient gift card balance; card not charged.'));
            }
            // your vendor call: persist $balance - $amount on the card
        }
    }
    
  3. Register it in the credit pool. Use xsi:type="string" so the pool resolves it lazily and skips it when the vendor module is absent:

    src/etc/di.xml
    <type name="Hyva\Pos\Model\CreditProviderPool">
        <arguments>
            <argument name="providers" xsi:type="array">
                <item name="acme_giftcard" xsi:type="string">Acme\PosBridge\Model\GiftCardProvider</item>
            </argument>
        </arguments>
    </type>
    
  4. Activate it under Stores → Configuration → Hyvä POS → Advanced → Credit & Return Providers as the gift-card provider. When nothing is configured, the pool auto-detects the first installed provider for the feature.

  5. Verify at the register:

    • Redeem a code on a sale and complete it. The vendor balance for that code must have decreased by the applied amount, exactly once.
    • Redeem two different codes on one sale. Both cards must be debited, each for its own amount.
    • Re-save the order (or let an offline-queued sale replay). No card may be debited twice - the core ledger skips the already-debited legs.

Prove It

The core ships an executable conformance kit (see Building a Bridge). Subclass AbstractCreditProviderConformanceTest and wire the two gift-card arrange* hooks to your vendor mocks:

tests/unit/GiftCardProviderConformanceTest.php
<?php
declare(strict_types=1);

namespace Acme\PosBridge\Test\Unit;

use Acme\PosBridge\Model\GiftCardProvider;
use Hyva\Pos\Api\CreditProviderInterface;
use Hyva\Pos\Api\GiftCardDebitProviderInterface;
use Hyva\Pos\TestFramework\Conformance\AbstractCreditProviderConformanceTest;
use Magento\Sales\Api\Data\OrderInterface;

class GiftCardProviderConformanceTest extends AbstractCreditProviderConformanceTest
{
    protected function createProvider(): CreditProviderInterface
    {
        return new GiftCardProvider(/* your vendor mocks */);
    }

    protected function arrangeGiftCardDebitSucceeds(
        GiftCardDebitProviderInterface $provider,
        OrderInterface $order
    ): void {
        // your vendor mock: make the lookup for $this->giftCardCode() return a card
        // funded above $this->debitAmount()
    }

    protected function arrangeGiftCardDebitFails(
        GiftCardDebitProviderInterface $provider,
        OrderInterface $order
    ): void {
        // often a no-op: the default missing-card lookup already fails
    }
}

For a gift-card provider the kit asserts that the provider code and feature type are valid, that refundCredit fails without a customer and rejects non-positive amounts (the "must mention customer" wording check applies to store credit only - your gift-card message may differ), that debitGiftCardForOrder calls through with the exact (code, amount) pair from giftCardCode() and debitAmount() without throwing when the card is funded, and that a vendor failure propagates as a LocalizedException rather than a soft zero-result. Scenarios for optional interfaces you do not implement (memo issuance, wallet debit, reward-points debit) are skipped, not failed. Assertions are message-pattern based, so your cashier-facing wording stays yours.

Gotchas

  • refundCredit is not the refund path. POS refunds route through the creditmemo / store-credit flow; store credit is the only tender the POS return wizard can refund to, so there is no POS product flow that refunds onto a gift card. Return an actionable failure result from refundCredit pointing the cashier at the right flow, as the skeleton above does.
  • Consumption-return is optional vendor sugar. Crediting a card back for what a POS order originally spent (on a creditmemo) is a vendor-specific enhancement outside the third-party contract - the Amasty bridge ships it as a creditmemo-save plugin that reads the core debit ledger. You do not need it for a conformant bridge.
  • Compare balances with an epsilon. Both shipped bridges use $balance + 0.0001 < $amount in the debit check, so a card redeemed for exactly its balance is not rejected by float rounding.
  • checkGiftCard returns null, it never throws. The app treats null as "no redeemable card"; a thrown exception from a balance lookup breaks the redemption UI. Catch vendor errors, log them, and return null.
  • Do not require a customer anywhere. Guest sales redeem gift cards too; CreditApplyRequestInterface::getCustomerId() is nullable for exactly this reason.