Skip to content

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, and getStatusLabel() must carry a populated, human-readable vendor title. A vendor title in getStatus() 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 TERMINAL state - 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 example received_partially -> received); you never invent a distinction your backend lacks.

The Interface

Implement Hyva\Pos\Api\RmaProviderInterface - all methods.

getOrderReturns

public function getOrderReturns(int $orderId): array;

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

public function addComment(int $rmaId, string $comment, bool $notifyCustomer = false): bool;

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

public function getProviderCode(): string;

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:

  1. 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.
  2. 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.
  3. Statuses only from the vocabulary. getStatus() speaks RmaStatusVocabulary, 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:

src/Model/RmaProvider.php (mapping excerpts)
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

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\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

  1. Create a return at the register. It must appear in your vendor's backend with the requested items and the initial comment.
  2. Check the POS open-returns inbox. The new return shows up - proof your getStatus() maps to an OPEN code and the sentinel listing works.
  3. Receive the items. The vendor status advances, and the return's items now report returned/approved quantities.
  4. Settle. The core builds the credit memo - verify the memo lines match the returned/approved quantities and the restock followed the received count.
  5. 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:

tests/unit/RmaProviderConformanceTest.php
<?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 so getOrderReturns(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 more RmaData with a known vendor status and return them. The kit asserts every getStatus() is a canonical vocabulary code and every getStatusLabel() 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 a LocalizedException whose message matches the pattern - pattern-based so your cashier-facing wording stays yours and localizable.
  • arrangeProcessedReturnItems() - arrange a received return and return its RmaItemData list. The kit asserts each item reports positive qtyReturned and qtyApproved.

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's changeStatus is 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 null from that hook.
  • processReturn throwing 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 createReturn does 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 returns null from arrangeUnmatchedLabelCreate() and arrangeProcessedReturnItems() 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.