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, ask what the cart may spend of it, and charge the card when the order saves. Unlike store credit, which is 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 and records each charge once per sale.

This page covers what is specific to gift cards. The CreditProviderInterface methods every credit provider implements (applicableCredit, appliesAs, readRefundLegs, readInvoiceLegs and the rest) are described on Store Credit. 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 Purpose
CreditProviderInterface Look a card up, answer what a cart may spend of it, read the card legs on documents; refundCredit is a stub
GiftCardDebitProviderInterface Debit one card per code on order save, and write the vendor's record of the cards the order spent
namespace Hyva\Pos\Api;

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

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

    /** @param \Hyva\Pos\Api\Data\CreditLegInterface[] $legs one leg per card at its debited figure */
    public function mapOrderTotals(OrderInterface $order, array $legs): void;
}

The gift-card specifics of CreditProviderInterface:

  • getFeatureType() returns gift_card, and appliesAs() answers tender.
  • getCustomerCredits(int $customerId) returns []. Gift cards are code-based bearer instruments, not customer-linked balances; the app looks them up by code.
  • checkGiftCard(string $code) looks the code up in your vendor module and returns 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.
  • applicableCredit() receives the card's code on the request, and applied_credits holds the cards already on the cart; your vendor's share for gift cards is used by those first. The core caps the card at what the cart still owes, so a second card beyond the total answers max_amount 0 with "The sale is already covered."
  • refundCredit() is not the refund path for gift cards. Return an actionable failure result telling the cashier which flow to use instead.

The Per-Code Debit

debitGiftCardForOrder(OrderInterface $order, string $code, float $amount, bool $partialAllowed) charges the one card named by $code, in order currency. The core drives it once per redeemed code for a new order that carries extension_attributes.pos_gift_card_redemptions and a pos_sale_id, inside the order-save transaction, after the order row exists. Before each card it asks applicableCredit with the order's figures and the cards already booked, and caps the card at that answer and at what the order is worth less the credit already booked.

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

  • Lock the card's row in the vendor's own table with Hyva\Pos\Model\CreditDebit\RowLock::lock() and read the balance from the locked row.
  • Answer a CreditDebitResultInterface whose requested is $amount and whose code is $code. Take $amount when the locked balance covers it. Otherwise take nothing and answer refused, or with $partialAllowed (the offline drain) take the balance and answer partial.
  • An insufficient balance and an unknown card are outcomes. Throw only when the vendor write itself fails.
  • One card's outcome never blocks the next card or the order. A thrown vendor failure on one code is recorded as failed for that card only.

Idempotency is per (pos_sale_id, code) in the core hyva_pos_giftcard_debit_ledger, written in the order's transaction. The dispatcher parses pos_gift_card_redemptions (a JSON array of {code, amount}); a code named more than once is one leg at the sum of its amounts, so your debit is asked once per card. Your bridge never parses the redemptions itself and keeps no ledger of its own.

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 a guest order. Do not add a customer requirement of your own.

A Card of Another Website

Where your vendor ties a card to the website it was issued for, answer it as your vendor's checkout does: applicableCredit answers 0 with the reason "This gift card is for another website.", and the debit refuses it with the same reason. Adobe Commerce always ties a card to its website; Amasty does unless Allow Use on Multiple Websites is on. Both vendors check this on their quote paths only, which a register sale never reaches, so the bridge answers it.

mapOrderTotals: The Cards the Order Spent

mapOrderTotals(OrderInterface $order, array $legs) writes the vendor's own record of the cards the order spent, one leg per card at its debited figure; a refused card is not among them. It is called once, after every card's debit, inside the same transaction, and never when no card was debited. Persist with the order resource's saveAttribute() or the vendor's own repository, 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 built it from pos_gift_card_redemptions in a beforeSave plugin on OrderRepositoryInterface removes that plugin.

The reference bridges write: Adobe Commerce gift_cards_amount, its base and the serialized gift_cards list (one entry per card with its account id); Amasty its per-order record am_giftcard_order, saved through its own repository with the order id. For the lock, Amasty locks amasty_giftcard_account by account id and reloads the account past its repository cache before it writes the locked balance less the take; Commerce locks magento_giftcardaccount and sets the locked row on the card it loaded before charge().

The Card Legs on Documents

readRefundLegs() and readInvoiceLegs() answer the gift-card legs a memo gives back and an invoice consumed, as described on Store Credit. Adobe Commerce reads gift_cards_amount on the memo; Amasty reads the am_giftcard_creditmemo extension (base_gift_amount) through Amasty's own read handler. Both vendors take the cards first.

Contract Rules

  • applicableCredit never charges the card. The card is charged once, by debitGiftCardForOrder on order save.
  • The core owns orchestration and idempotency. The core parses the redemptions, keeps hyva_pos_giftcard_debit_ledger, caps the cards at the order's worth and books a shortfall. Your bridge never writes the order's grand total, its amount due or pos_gift_card_amount, and never registers its own OrderRepositoryInterface plugin to debit or to map its record.
  • A refused card is not an error. Answer refused (or partial offline) and let the core continue with the next code.
  • No instanceof guards against sibling bridges. The pool only ever invokes the active gift-card provider, and hands it one resolved (code, amount) pair per call.

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. The skeleton shows the gift-card methods; readRefundLegs() and readInvoiceLegs() follow the store-credit example with KIND_GIFT_CARD and your vendor's columns.

    src/Model/GiftCardProvider.php
    <?php
    declare(strict_types=1);
    
    namespace Acme\PosBridge\Model;
    
    use Acme\GiftCard\Api\CardRepositoryInterface;
    use Hyva\Pos\Api\{CreditProviderInterface, GiftCardDebitProviderInterface};
    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 GiftCardProvider implements CreditProviderInterface, GiftCardDebitProviderInterface
    {
        private const CODE = 'acme_giftcard';
    
        public function __construct(
            private readonly CardRepositoryInterface $cards, // your vendor service
            private readonly RowLock $rowLock
        ) {
        }
    
        public function getProviderCode(): string { return self::CODE; }
        public function getFeatureType(): string { return CreditLegInterface::KIND_GIFT_CARD; }
        public function appliesAs(string $kind): string { return CreditLegInterface::APPLIES_AS_TENDER; }
        public function getCustomerCredits(int $customerId): array { return []; }
    
        public function checkGiftCard(string $code): ?CreditBalanceInterface
        {
            $balance = $this->cards->findBalance($code); // your vendor call: null for an unknown card
            if ($balance === null || $balance <= 0.0) {
                return null; // unknown, empty, or inactive: null, never throw
            }
            return new CreditBalance(['type' => 'gift_card', 'provider' => self::CODE, 'label' => 'Gift Card',
                'balance' => $balance, 'code' => $code, 'is_redeemable' => true]);
        }
    
        public function applicableCredit(CreditApplicableRequestInterface $request): CreditApplicableInterface
        {
            $answer = (new CreditApplicable())->setCreditType(CreditLegInterface::KIND_GIFT_CARD)
                ->setProviderCode(self::CODE)->setCode($request->getCode())
                ->setAppliesAs(CreditLegInterface::APPLIES_AS_TENDER)->setMinAmount(0.0);
            $balance = $this->cards->findBalance((string) $request->getCode()) ?? 0.0; // your vendor call
            $lines = [];
            foreach ($request->getLines() as $line) {
                $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)
                ->setReason($balance > 0.0 ? null : 'This gift card has no balance.');
        }
    
        public function debitGiftCardForOrder(
            OrderInterface $order,
            string $code,
            float $amount,
            bool $partialAllowed
        ): CreditDebitResultInterface {
            $result = (new CreditDebitResult())->setKind(CreditLegInterface::KIND_GIFT_CARD)->setProviderCode(self::CODE)
                ->setAppliesAs(CreditLegInterface::APPLIES_AS_TENDER)->setCode($code)->setRequested($amount);
            $row = $this->rowLock->lock('acme_giftcard', ['code = ?' => $code]);
            $balance = (float) ($row['balance'] ?? 0.0);
            $take = $balance + 0.0001 >= $amount ? $amount : ($partialAllowed ? $balance : 0.0);
            if ($row === null || $take <= 0.0) {
                return $result->setDebited(0.0)->setOutcome(CreditDebitResultInterface::OUTCOME_REFUSED)
                    ->setReason($row === null ? 'This gift card does not exist.' : 'Insufficient gift card balance.');
            }
            // your vendor call: write the locked balance less $take; throws LocalizedException on failure
            $this->cards->writeBalance((int) $row['card_id'], $balance - $take);
            return $result->setDebited($take)->setOutcome(
                $take < $amount ? CreditDebitResultInterface::OUTCOME_PARTIAL : CreditDebitResultInterface::OUTCOME_DEBITED
            );
        }
    
        public function mapOrderTotals(OrderInterface $order, array $legs): void
        {
            foreach ($legs as $leg) { // your vendor's per-order record, one entry per card
                $this->cards->recordOrderCard((int) $order->getEntityId(), (string) $leg->getCode(), $leg->getAmount());
            }
        }
    
        public function refundCredit(CreditRefundRequestInterface $request): CreditApplyResultInterface
        {
            return (new CreditApplyResult())->setSuccess(false)->setAmountApplied(0.0)
                ->setErrorMessage('Refund this sale through a return; a gift card cannot be refunded directly.');
        }
    
        public function readRefundLegs(CreditmemoInterface $creditmemo): array { /* as on Store Credit */ return []; }
        public function readInvoiceLegs(InvoiceInterface $invoice): array { /* as on Store Credit */ return []; }
    }
    
  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 decreases by the applied amount, once.
    • Redeem two different codes on one sale. Both cards are debited, each for its own amount.
    • Re-sync the same sale (or let an offline-queued sale replay). No card is debited twice.
    • Redeem a code issued for another website, where your vendor ties cards to a website. The register shows it as unavailable.

Prove It

The core ships an executable conformance kit (see Building a Bridge). Subclass AbstractCreditProviderConformanceTest and wire the 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: lock $this->giftCardCode() to a card funded above $this->debitAmount()
    }
}

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 the customer" wording applies to store credit only), that appliesAs answers tender every time and matches the applicable answer, and that applicableCredit answers one of the three outcomes and allows nothing unless supported. With GiftCardDebitProviderInterface implemented it asserts that a funded card answers debited for the whole request with its code, and that a card it cannot take answers refused (or unsupported) without throwing (arrangeGiftCardDebitFails() is often a no-op, since the default missing-card lookup already fails).

Clauses with a hook that answers true once arranged: the debit's arithmetic uses the locked row (arrangeLockedRowBelowEarlierRead), a debit of the read's own maximum or of any multiple of its amount_step up to it is taken online (arrangeReadAndDebitAtTheMaximum), a card of another website is neither offered nor taken (arrangeGiftCardOfAnotherWebsite), and a card your vendor lets pay no tax stops at the net with a reason (arrangeNoTaxCoverage). A vendor with a product rule for gift cards arranges the two line clauses as well (arrangeOneIneligibleLine, arrangeNoEligibleLine).

Gotchas

  • refundCredit is not the refund path. POS refunds route through the credit memo; the card legs a memo gives back are read by readRefundLegs. Return an actionable failure result from refundCredit, as the skeleton does.
  • Crediting a card back for what a POS order spent is optional. It is vendor-specific behavior outside the contract; the Amasty bridge implements it as a credit memo save plugin that reads the core debit ledger.
  • Compare balances with an epsilon. The shipped bridges use $balance + 0.0001 >= $amount, so a card redeemed for exactly its balance is not refused 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; CreditApplicableRequestInterface::getCustomerId() is nullable for this reason.