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 throughgetFeatureType():store_credit,gift_cardorreward_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:
- 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.
- 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. - Failure semantics differ by direction. On a checkout debit, a short wallet is an outcome, not an exception: the provider answers
refusedonline and takes what the wallet holds offline. A thrown vendor failure is recorded asfailedwith 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. - 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.
applicableCreditnever 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,applicableCreditandPOST /V1/pos/credits/applicablereplaceapplyCreditandPOST /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:
{
"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:
<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:
<?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:
<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:compileagain. It must succeed, with your bridge absent frommodule: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:
<?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.