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 viacheckGiftCard().checkGiftCard(string $code): ?CreditBalanceInterface- look the code up in your vendor module and return aCreditBalance(typegift_card, your provider code, the live balance, currency, the code,is_redeemable, optionallyexpires_at). Returnnull- 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 viasetAmountApplied()- 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 exampleacme_giftcard.getFeatureType(): string- returngift_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 coreDispatchGiftCardDebitPlugincalls it once per redeemed code inside the order-save flow, after the order row exists, whenever a new order carriesextension_attributes.pos_gift_card_redemptionsand apos_sale_id. You receive one resolved(code, amount)pair per call, with$amountin the order's currency. Idempotency is already guaranteed before your code runs: the core records one debit per(pos_sale_id, code)in itshyva_pos_giftcard_debit_ledgertable and skips any leg it has already debited. Throw aLocalizedExceptionon failure (unknown code, insufficient balance).
Contract Rules
applyCreditis validate-only. The card is charged exactly once, bydebitGiftCardForOrderon order save. A provider that mutates the card insideapplyCreditdouble-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_redemptionsextension attribute (a JSON array of{code, amount}) and keeps thehyva_pos_giftcard_debit_ledger. Your bridge never parses the redemptions itself, never keeps its own idempotency records for this flow, and never registers its ownOrderRepositoryInterfaceplugin to debit at checkout. - A debit failure never loses the order. Throw a
LocalizedExceptionfromdebitGiftCardForOrder; 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_idisNULLfor guest orders. Do not add a customer requirement of your own. - No vendor-specific values reach the app. Report balances through the
CreditBalanceshape, and keep error messages set viasetErrorMessage()human-readable - they are shown to the cashier verbatim, so never echo raw exception text. - No
instanceofguards against sibling bridges. The pool only ever invokes the active gift-card provider; co-install safety is structural.
Build It
-
Scaffold the module, guard
registration.phpon a real vendor interface, and declare the load order as described in Building a Bridge. -
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 } } -
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: -
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.
-
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:
<?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
refundCreditis 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 fromrefundCreditpointing 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 < $amountin the debit check, so a card redeemed for exactly its balance is not rejected by float rounding. checkGiftCardreturnsnull, it never throws. The app treatsnullas "no redeemable card"; a thrown exception from a balance lookup breaks the redemption UI. Catch vendor errors, log them, and returnnull.- Do not require a customer anywhere. Guest sales redeem gift cards too;
CreditApplyRequestInterface::getCustomerId()is nullable for exactly this reason.