RMA Providers
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.
Returns at the register follow one flow regardless of which RMA extension the store runs. The cashier sees an open-returns inbox listing every return still in flight across all orders, creates a return against an order (with reasons, conditions and resolutions where the backend supports them), receives the items back with an inspection verdict and restock routing, and settles - the core builds the credit memo and hands the money back via cash, terminal, original payment, store credit or exchange.
An RMA provider maps that flow onto your returns backend: listing, creation, status transitions and comments go through your module's services. The core keeps ownership of the credit memo and the refund itself. Exactly one provider is active per install; scaffolding, registration guards and the pool architecture are covered in Building a Bridge - this page covers the RMA contract and a worked implementation.
The Canonical Vocabulary
Every status your provider emits must come from Hyva\Pos\Api\RmaStatusVocabulary. The codes split into two lifecycle buckets:
| Bucket | Canonical codes |
|---|---|
OPEN (still in flight) |
pending, authorized, partially_authorized, received, received_partially, approved, awaiting_refund |
TERMINAL (settled end state) |
resolved, closed, processed_closed, rejected, denied |
The rules:
RmaDataInterface::getStatus()must return one of these codes, andgetStatusLabel()must carry a populated, human-readable vendor title. A vendor title ingetStatus()makes the return invisible to POS: the open-returns filter and the settle idempotency check both compare against this vocabulary.- Terminal states are sticky. A provider must never move a return out of a
TERMINALstate - that is what keeps settle retries idempotent. - The
partially_*members are generic RMA concepts, not one vendor's model. A backend that cannot distinguish a partial state maps to the nearest full member (for examplereceived_partially->received); you never invent a distinction your backend lacks.
The Interface
Implement Hyva\Pos\Api\RmaProviderInterface - all methods.
getOrderReturns
Returns Hyva\Pos\Api\Data\RmaDataInterface[]. $orderId === 0 is the all-RMAs sentinel: it means "every return across every order", and you must not apply an order filter in that case. The cross-order POS surfaces (open-returns inbox, single-return lookup, settle) have no order in hand and rely on the sentinel. Any positive $orderId filters to that order.
Once a return's items have been processed, each RmaItemDataInterface must populate getQtyReturned() and getQtyApproved(). These quantities drive the credit memo the core builds: memo lines follow qtyReturned, falling back to qtyApproved; the refund follows the requested/approved quantity; the restock follows the received quantity. A provider that leaves them at zero makes the core refuse to settle - loudly - rather than close a return with no memo.
createReturn
public function createReturn(
\Hyva\Pos\Api\Data\RmaCreateRequestInterface $request
): \Hyva\Pos\Api\Data\RmaDataInterface;
The request carries the order id, customer id, an optional comment, and the items as a JSON string of [{order_item_id, qty_requested, reason, condition, resolution}].
The unmatched-label rule: a reason, condition or resolution label your backend does not recognize must raise a cashier-actionable LocalizedException naming the label. Never silently drop it - that creates a return the cashier believes carries a reason it never recorded. A backend that auto-creates options (like Adobe Commerce's EAV attributes) has no unmatched path and is exempt.
updateStatus
public function updateStatus(
int $rmaId,
string $status,
?string $comment = null
): \Hyva\Pos\Api\Data\RmaDataInterface;
$status is a POS lifecycle name, not a vendor status. Accept and translate all of: approved, items_received, awaiting_refund, resolved, closed. Vendor-specific bookkeeping that a transition needs (populating qty_authorized / qty_returned / qty_approved for a save validator, for example) happens here.
Transitions are forward-only and terminal states are sticky: a request to move backward, or to leave a TERMINAL state, is a silent no-op that returns the return in its current state. A re-issued "close" on an already-closed return must return cleanly instead of erroring - that is what makes settle retries idempotent.
addComment
Adds a comment to the return's thread. Return false on failure instead of throwing; a lost comment must not abort a settle.
processReturn and processExistingReturn
public function processReturn(
\Hyva\Pos\Api\Data\RmaCreateRequestInterface $request,
string $resolution,
bool $isOnline = false
): \Hyva\Pos\Api\Data\ReturnProcessResultInterface;
public function processExistingReturn(
int $rmaId,
string $resolution,
bool $isOnline = false
): \Hyva\Pos\Api\Data\ReturnProcessResultInterface;
Optional fast-paths: create (or take an existing) return, fast-track to approved, create the credit memo and apply the resolution in one step. $resolution is "refund", "store_credit" or "exchange"; $isOnline refunds via the payment gateway. Throwing here is a supported, non-fatal signal, not an error surfaced to the cashier: the core falls back to its own memo-only settle flow, which drives createReturn() / updateStatus() and builds the credit memo directly. A bridge that does not implement them throws.
getProviderCode
A stable identifier such as "aheadworks_rma". The admin provider dropdown and the pool's resolution key on it.
Contract Rules
The bridge overview carries the rules shared by every provider type. Specific to RMA:
- One active provider per install. The merchant's selection under Stores → Configuration → Hyvä POS → Advanced → Credit & Return Providers wins. With nothing configured, the pool auto-detects: the first installed vendor bridge wins, and the built-in POS returns flow is the last-resort fallback. A configured bridge that is not installed produces an "RMA provider unavailable" error - never a silent fallback to another provider.
- No self-guarding. Your provider never checks configuration or probes sibling bridges; the pool resolves the active provider, and your methods only run when you are it.
- Statuses only from the vocabulary.
getStatus()speaksRmaStatusVocabulary,getStatusLabel()carries the vendor title, and the label is never empty.
Build It
1. Scaffold the Module
Follow Building a Bridge for the composer package, module.xml sequence and the registration.php guard on a real vendor interface. Nothing RMA-specific happens at this step.
2. Implement the Provider
Model it on the shipped Aheadworks bridge (magento2-hyva-pos-aheadworks, Hyva\PosAheadworksRma\Model\RmaProvider). Its two mapping patterns transfer to almost any backend:
Outbound - vendor status to canonical code. Aheadworks statuses carry no state buckets, only merchant-renameable labels, so the mapping is label-based: an alias table for the stock vendor names, then exact and substring matching against the vocabulary, with pending as the unmatched fallback (an over-visible open row beats a silently hidden return). If your backend has real state fields, map those instead - it is more robust than labels.
Inbound - POS lifecycle name to vendor transition. Two lifecycle names rarely have a literal vendor equivalent, so alias them to the nearest concept before resolving:
private function resolveVendorStatus(string $posLifecycleName): ?int
{
// POS lifecycle names with no direct vendor equivalent map to
// the nearest concept: the receive step and the "parked for a
// cash handback" step.
$aliases = [
'items_received' => 'received',
'awaiting_refund' => 'approved',
];
$needle = $aliases[strtolower($posLifecycleName)] ?? strtolower($posLifecycleName);
// ...resolve $needle against your backend's statuses...
}
public function updateStatus(int $rmaId, string $status, ?string $comment = null): RmaDataInterface
{
// Terminal stickiness: check the mapped state BEFORE touching
// the backend, so a re-issued close never reaches the vendor.
$current = $this->getReturn($rmaId);
if ($current !== null
&& in_array($current->getStatus(), \Hyva\Pos\Api\RmaStatusVocabulary::TERMINAL, true)
) {
return $current;
}
// ...resolve the vendor status, transition, reload and map...
}
Build your RmaDataInterface objects with the core's Hyva\Pos\Model\Data\RmaData, RmaItemData and RmaCommentData implementations. Populate productName and productSku on each item (join via OrderItemRepositoryInterface if your backend only stores order item ids - without it the cashier sees an empty row with just a quantity). Leave refundMethod alone unless your backend natively knows the settlement channel: the core derives it from its own settlement audit-comment trail and leaves a natively-set value untouched.
Note one known gap in the reference implementation: the Aheadworks bridge's createReturn does not yet thread reason, condition and resolution labels through to Aheadworks - it stores order item ids and quantities only. That gap is recorded honestly by its conformance test (see Gotchas); do not copy it into a backend that can carry the labels.
3. Register on the Pool
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Hyva\Pos\Model\RmaProviderPool">
<arguments>
<argument name="providers" xsi:type="array">
<item name="acme_rma" xsi:type="string">Acme\PosBridge\Model\RmaProvider</item>
</argument>
</arguments>
</type>
</config>
Use xsi:type="string": the pool resolves string entries lazily inside a try/catch, so a bridge whose vendor module is absent is skipped instead of crashing DI compilation.
4. Activate It
Run setup:upgrade and setup:di:compile, then select your provider under Stores → Configuration → Hyvä POS → Advanced → Credit & Return Providers. Auto-detect also picks it up when no vendor bridge outranks it in declared order, but an explicit selection removes the ambiguity.
5. Verify at the Register
- Create a return at the register. It must appear in your vendor's backend with the requested items and the initial comment.
- Check the POS open-returns inbox. The new return shows up - proof your
getStatus()maps to anOPENcode and the sentinel listing works. - Receive the items. The vendor status advances, and the return's items now report returned/approved quantities.
- Settle. The core builds the credit memo - verify the memo lines match the returned/approved quantities and the restock followed the received count.
- Settle again (simulate a retry). The already-closed return comes back unchanged, with no error and no second memo.
Prove It
Subclass Hyva\Pos\TestFramework\Conformance\AbstractRmaProviderConformanceTest in your unit suite. The kit owns the assertions; you supply only the vendor wiring through createProvider() and five scenario hooks:
<?php
declare(strict_types=1);
namespace Acme\PosBridge\Test\Unit;
use Acme\PosBridge\Model\RmaProvider;
use Hyva\Pos\Api\RmaProviderInterface;
use Hyva\Pos\TestFramework\Conformance\AbstractRmaProviderConformanceTest;
class RmaProviderConformanceTest extends AbstractRmaProviderConformanceTest
{
protected function createProvider(): RmaProviderInterface
{
return new RmaProvider(/* your vendor mocks */);
}
protected function arrangeSentinelList(): ?RmaProviderInterface { /* ... */ }
protected function arrangeReturnsForStatusCheck(): ?array { /* ... */ }
protected function arrangeTerminalReturnForBackwardMove(): ?array { /* ... */ }
protected function arrangeUnmatchedLabelCreate(): ?array { /* ... */ }
protected function arrangeProcessedReturnItems(): ?array { /* ... */ }
}
What each hook arranges, and what the kit then asserts:
arrangeSentinelList()- configure your list backend sogetOrderReturns(0)runs, and set the no-filter expectation on your own SearchCriteria or collection double (expects($this->never())->method('addFilter')). The kit drives the call so your expectation fires.arrangeReturnsForStatusCheck()- emit one or moreRmaDatawith a known vendor status and return them. The kit asserts everygetStatus()is a canonical vocabulary code and everygetStatusLabel()is non-empty.arrangeTerminalReturnForBackwardMove()- arrange an already-terminal return and return[provider, rmaId, expectedCanonicalStatus, backwardStatus]. The kit issues the backward move and asserts the status is unchanged.arrangeUnmatchedLabelCreate()- arrange a create whose label cannot map and return[provider, request, messagePattern]. The kit asserts aLocalizedExceptionwhose message matches the pattern - pattern-based so your cashier-facing wording stays yours and localizable.arrangeProcessedReturnItems()- arrange a received return and return itsRmaItemDatalist. The kit asserts each item reports positiveqtyReturnedandqtyApproved.
Null-hook skip semantics: a scenario your backend genuinely cannot reach returns null from its hook, and the kit skips that one assertion instead of failing. A skip is a documented capability statement, not a shortcut - put the reason in a comment, as the shipped bridges do.
Gotchas
- A vendor title in
getStatus()hides every return from POS. The open-returns filter and the settle idempotency exit compare canonical lowercase codes. Returning the raw vendor title is exactly the bug that kept every Aheadworks RMA out of the open-returns list in an early version of that bridge - the conformance kit now catches it. - Guard terminal stickiness before calling the vendor. The Aheadworks bridge maps the current status first and returns early on
TERMINAL, so the vendor'schangeStatusis never invoked on a closed return. Guarding after the vendor call is too late. - Refund follows requested/approved; restock follows received. A cashier receiving 3 of 5 items as restockable still refunds all 5 - only the restock drops to the received count. Do not "fix" your quantity bookkeeping to make refunds follow received quantities; that silently shrinks customers' refunds.
- Zero settlement quantities block settle, by design. If your backend has no per-item approved/returned fields, populate them in your
updateStatus()bookkeeping when the receive transition lands - otherwise the core refuses to close the return. - Unmatched labels throw with the label in the message. The conformance kit matches the thrown message against a pattern that names the label, so the cashier can fix the actual input. Backends that auto-create options have no unmatched path - return
nullfrom that hook. processReturnthrowing is not a failure. The core catches it and runs its memo-only settle flow. Do not fake a fast-path you cannot fully honor.- An honest skip beats a fake pass. The Aheadworks bridge's
createReturndoes not thread reason/condition/resolution labels (Aheadworks stores item ids and quantities only), and its item rows carry no approved/returned quantities. Its conformance test returnsnullfromarrangeUnmatchedLabelCreate()andarrangeProcessedReturnItems()with comments explaining exactly why. When your backend has a real capability gap, record it the same way - a documented skip tells the next developer the truth; a green test that mocks around the gap does not.