Skip to content

Building a Bridge

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.

Bridges are how third-party modules plug into Hyvä POS. The app calls one universal API for store credit, gift cards, reward points, RMA and the other provider-backed features; on the Magento side, a bridge translates that call to your module's own services. Adobe Commerce, Aheadworks, Amasty and Mirasvit bridges ship ready-made; this section is for building your own.

This page walks through building a complete bridge module, from empty directory to passing conformance tests. Each provider type then has its own page with the interface, the contract rules, and a worked implementation:

Provider type Page Pool
Store credit Store Credit Hyva\Pos\Model\CreditProviderPool
Gift cards Gift Cards Hyva\Pos\Model\CreditProviderPool
Reward points Reward Points Hyva\Pos\Model\CreditProviderPool
Returns / RMA RMA Providers Hyva\Pos\Model\RmaProviderPool
Shipping labels Shipping Label Providers Hyva\Pos\Model\ShippingLabelProviderPool
Credit limits Credit Limit Resolvers Hyva\Pos\Model\Customer\CreditLimitResolverPool
Customer validation Customer Validators Hyva\Pos\Model\Customer\CustomerValidatorPool
Customer pricing Customer Pricing Resolvers Hyva\Pos\Model\Customer\CustomerPriceResolverPool

The Pool Architecture

Every provider type has a pool. The pool holds every registered provider of its kind and resolves the active one; the core module only ever invokes the active provider. That makes co-installation safe structurally: your bridge never checks instanceof against a sibling bridge, and two wallet vendors can be installed side by side while only the configured one handles POS traffic.

The pools resolve the active provider in two ways:

  • Credit providers (CreditProviderPool) declare a feature type through getFeatureType(): store_credit, gift_card or reward_points. The merchant picks one active provider per feature in Stores → Configuration → Hyvä POS → Advanced → Credit & Return Providers. When nothing is configured, the pool auto-detects the first installed provider for that feature.
  • RMA providers (RmaProviderPool): exactly one is active per install. A configured vendor bridge wins; otherwise the built-in POS returns flow is used.

The other pools behave differently:

  • The customer validator pool does not pick one winner. Every enabled validator runs and their results merge into one list, so the cashier sees every problem in a single save round-trip. Which validators are enabled is the merchant's Customer Validators setting; empty, the default, runs every registered validator.
  • The shipping label pool resolves by carrier: each provider declares which shipments it can handle via canHandle(), and the first match wins.
  • The credit limit and customer pricing pools walk their resolvers in declared order. For a credit limit the first non-null answer wins; for prices the first resolver to answer a SKU wins that SKU.

The Contract Rules

These rules apply to every bridge:

  1. The core orchestrates, the bridge supplies the vendor call. The checkout debits run inside the database transaction that saves the order, and the core drives them. A refund to store credit is booked by Magento's own refund call, and the core confirms the wallet write afterwards. Your provider never registers its own order or credit memo plugin for these flows, and opens no transaction of its own except around a vendor wallet write that runs after the memo transaction.
  2. The core owns idempotency. Ledger tables in the core record what has been applied: one debit per pos_sale_id (per code for gift cards) and one store-credit issuance per credit memo. Retries (an offline queue draining twice, a settle retried) cannot debit or credit twice. Your provider keeps no idempotency records of its own.
  3. Failure semantics differ by direction. On a checkout debit, a short wallet is an outcome, not an exception: the provider answers refused online and takes what the wallet holds offline. A thrown vendor failure is recorded as failed with nothing taken, and the order saves. Nothing retries a failed debit later. On a refund to store credit, the memo stands once Magento's refund call returned. A wallet write the core cannot confirm fails the settle with the memo named, and the retry confirms the same memo without a second write.
  4. Speak the canonical vocabulary. RMA providers map their vendor's statuses onto the canonical status set; credit providers answer in the shapes the contract defines. The app never sees vendor-specific values.
  5. applicableCredit never mutates a wallet, for any wallet type. The register asks what a cart may spend, and the core debits on order save. Since module 1.3.0, applicableCredit and POST /V1/pos/credits/applicable replace applyCredit and POST /V1/pos/credits/apply, which were removed. A bridge written for an earlier module implements the new method and the capability interfaces described on its provider page.

What the Core Owns

The core owns (never duplicate it) A bridge must never
The order transaction around the checkout debits and mapOrderTotals Open its own transaction around a debit
The order mutex around the refund-to-credit confirmation and its repair write Open its own begin/commit for these flows, except around a vendor wallet write that runs after the memo transaction
The idempotency ledgers (hyva_pos_wallet_debit_ledger, hyva_pos_giftcard_debit_ledger, hyva_pos_reward_debit_ledger, hyva_pos_memo_credit_ledger) Keep its own idempotency records for these flows
The pos_store_credit_issued confirmation column Write pos_store_credit_issued
Active-provider resolution and the registered-customer, new-order and zero-amount guards Check instanceof against another bridge
Driving prepareRefundArguments → refund call → creditedByVendor → issueRefundToWallet Register its own CreditmemoRepositoryInterface plugin for refund to credit
Driving applicableCredit → the debit → mapOrderTotals on order save, and booking the outcome (the credit columns, an amount due, pos_credit_outcomes) Register its own OrderRepositoryInterface plugin to debit at checkout or to map its columns
The credit memo of a return processed in one call (ReturnMemoSettlement) Issue store credit beside a return's memo

The core writes no vendor column. Writing the vendor's own record of what an order spent is the bridge's mapOrderTotals, called by the core after the debit with the debited legs.

Anatomy of a Bridge Module

The shipped example bridges are the reference skeleton. A minimal bridge is seven files:

magento2-acme-pos-bridge/
├── composer.json
├── src/
│   ├── registration.php
│   ├── etc/
│   │   ├── module.xml
│   │   └── di.xml
│   └── Model/
│       └── StoreCreditProvider.php
└── tests/
    ├── bootstrap.php
    └── unit/
        └── StoreCreditProviderConformanceTest.php

Build One Step by Step

1. Scaffold the Module

Declare the package as a magento2-module and autoload registration.php:

composer.json
{
    "name": "acme/magento2-acme-pos-bridge",
    "description": "Connects Acme Wallet to Hyva POS",
    "type": "magento2-module",
    "require": {
        "php": ">=8.1",
        "hyva-themes/magento2-hyva-pos": "^1.3"
    },
    "autoload": {
        "files": ["src/registration.php"],
        "psr-4": { "Acme\\PosBridge\\": "src/" }
    }
}

Do not add a hard composer requirement on your vendor module if merchants might install your bridge without it; the registration guard in step 2 handles the missing-vendor case, and a suggest entry documents the relationship without forcing it.

Declare the load order so the pool exists before your di.xml plugs into it:

src/etc/module.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Acme_PosBridge">
        <sequence>
            <module name="Hyva_Pos"/>
            <module name="Acme_Wallet"/>
        </sequence>
    </module>
</config>

2. Guard the Registration

Your provider constructor-injects the vendor's typed interfaces. During setup:di:compile, PHP reflection autoloads those parameter types, which fails the compile if the vendor module is absent. Guard registration.php on a vendor interface so the bridge does not register when the vendor is missing:

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

use Magento\Framework\Component\ComponentRegistrar;

if (!interface_exists(\Acme\Wallet\Api\WalletRepositoryInterface::class)) {
    return;
}

ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Acme_PosBridge', __DIR__);

Guard on a real vendor class or interface, never on a code-generated *Factory or *Extension class. Generated classes are not reliably autoloadable during CLI module discovery, so a factory guard makes your module silently deregister itself on setup:upgrade even when the vendor is installed.

3. Implement the Provider

The provider pages cover this step: each one documents its interfaces with full signatures, the contract clauses, and a worked implementation. Pick yours from the table at the top of this page. A credit provider implements CreditProviderInterface plus the capability interfaces for its kind:

Kind Capability interfaces
Store credit WalletDebitProviderInterface (the debit at order save), MemoCreditIssuerInterface (the refund to store credit)
Gift cards GiftCardDebitProviderInterface (the per-code debit at order save)
Reward points RewardPointsDebitProviderInterface (the debit at order save); optionally RewardPointsEarnedReaderInterface and RewardPointsEarnProviderInterface

The CreditProviderInterface methods every credit provider implements are described once, on Store Credit; the gift-card and reward-points pages add what differs for their kind.

4. Register It in the Pool

Add your provider to the pool's array argument:

src/etc/di.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Hyva\Pos\Model\CreditProviderPool">
        <arguments>
            <argument name="providers" xsi:type="array">
                <item name="acme_store_credit" xsi:type="string">Acme\PosBridge\Model\StoreCreditProvider</item>
            </argument>
        </arguments>
    </type>
</config>

Whether the item is xsi:type="string" or xsi:type="object" depends on the pool, and getting it wrong on the small pools fails silently:

Pool Argument Item type
CreditProviderPool providers string, resolved lazily in a try/catch, so an absent vendor is skipped instead of crashing DI compilation
RmaProviderPool providers string, same lazy resolution
ShippingLabelProviderPool providers object; no string resolution and no type check; a string entry breaks the label print and the GET /V1/pos/config read
CreditLimitResolverPool resolvers object; a string entry is silently skipped and your resolver never runs
CustomerValidatorPool validators object
CustomerPriceResolverPool resolvers object

For the object pools, the missing-vendor case is handled entirely by the registration guard from step 2: when the vendor is absent your module never registers, so the pool never sees the entry. Each provider page shows the registration for its pool.

5. Whitelist Any New Schema

If your bridge declares its own db_schema.xml tables or columns, add them to db_schema_whitelist.json. Declarative schema silently skips element modifications that are not whitelisted; the symptom is a table that never appears, with no error anywhere.

6. Install and Verify

composer require acme/magento2-acme-pos-bridge
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento module:status Acme_PosBridge

Then verify the wiring end to end: set your provider as active under Stores → Configuration → Hyvä POS → Advanced → Credit & Return Providers, and exercise the feature from the POS app. Each provider page lists what to check at the register.

Two checks worth doing before you call it done:

  • Uninstall (or rename) the vendor module and run setup:di:compile again. It must succeed, with your bridge absent from module:status. That proves the registration guard.
  • Reinstall the vendor and run setup:upgrade. Your bridge must re-register. That proves the guard is on an autoloadable class.

Prove It: the Conformance Kit

The core ships an executable conformance kit under Hyva\Pos\TestFramework\Conformance\: abstract PHPUnit test cases that encode the contract as assertions. Subclass the abstract for your provider type and supply only the vendor wiring:

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

namespace Acme\PosBridge\Test\Unit;

use Acme\PosBridge\Model\StoreCreditProvider;
use Hyva\Pos\Api\CreditProviderInterface;
use Hyva\Pos\TestFramework\Conformance\AbstractCreditProviderConformanceTest;

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

Scenarios are arranged through arrange*() hooks you wire to your vendor mocks. Four abstracts ship:

Abstract Provider types What it asserts
AbstractCreditProviderConformanceTest Store credit, gift cards, reward points Provider code and feature type, refundCredit input checks, appliesAs and the three applicableCredit outcomes, and, for each capability interface your provider implements, its debit, refund-to-credit and earned-points clauses
AbstractRmaProviderConformanceTest RMA The all-returns sentinel, the canonical vocabulary, forward-only and terminal-sticky transitions, the unmatched-label rule, settlement quantities, and with the optional hooks the options, eligibility, parked state and partial receive
AbstractCustomerValidatorConformanceTest Customer validators An indexed list of well-formed rows, and no exception when the backend fails
AbstractCustomerPriceResolverConformanceTest Customer pricing [] for an unknown customer without throwing, and every row a genuine discount

Skip semantics differ by kit. The credit kit skips the scenarios of a capability interface your provider does not implement, and a clause with an arrange hook that answers false until you arrange it. The RMA, validator and price kits skip a scenario when its arrange hook returns null, for a case your backend cannot reach. Assertions are message-pattern based, so your cashier-facing wording stays yours (and localizable). All four shipped bridges run the kit in CI; yours should too. The hooks for each provider type are listed on its provider page. Shipping labels and credit limits have no abstract; their pages say what to unit-test instead.

Reference Implementations

Package What it demonstrates
magento2-hyva-pos-adobe-commerce Store credit, gift cards, reward points and returns on Adobe Commerce's native modules, including the refund to store credit through Commerce's own memo observer
magento2-hyva-pos-amasty Amasty Store Credit, Gift Card, Reward Points (applied as a discount) and RMA, including the ledger row a bridge writes for a wallet credit outside the memo transaction
magento2-hyva-pos-aheadworks Aheadworks Store Credit, Gift Card, Reward Points and RMA
magento2-hyva-pos-mirasvit Mirasvit Store Credit and Reward Points
magento2-hyva-pos-geissweb-euvat-bridge A customer validator on a third-party service: VAT number format and VIES lookup through Geissweb EU VAT Enhanced
Example bridges Two small proof-of-concept bridges (a customer validator and a customer price resolver) built to be read top to bottom

Where to Start

Read the provider page for your feature, then clone one of the example bridges and adapt it. The examples carry the same file layout, registration guard commentary, and conformance test adoption you need in a real bridge.