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 the reasons, conditions and resolutions the backend offers, within its return rules), receives the items back with an inspection verdict and restock routing, and settles: the core builds the credit memo and refunds via cash, terminal, original payment, store credit or exchange.

An RMA provider maps that flow onto your returns backend: options, eligibility, 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 never moves a return out of a TERMINAL state, which 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 to received); you never invent a distinction your backend lacks.

The Interface

Implement Hyva\Pos\Api\RmaProviderInterface, all methods. processReturn and processExistingReturn may decline (below).

getProviderCode

public function getProviderCode(): string;

A stable identifier such as "aheadworks_rma". The admin provider selection and the pool's resolution key on it.

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 it. 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 and approved quantity; the restock follows the received quantity. A provider that leaves them at zero makes the core refuse to settle rather than close a return with no memo.

RmaDataInterface::getRefundMethod() reports the settlement channel (original_payment, cash, terminal, exchange or store_credit). A bridge does not need to populate it: the core derives it on every read from its own settlement comments. A bridge that knows the channel natively may set it, and the core leaves that value untouched. The core also adds the fields it owns to every read of a return it recorded (pos_sale_id, ready, the refund attempt, and on a closed return the legs its memo booked); a bridge does nothing for them.

getReturnOptions

public function getReturnOptions(int $storeId): \Hyva\Pos\Api\Data\ReturnOptionsInterface;

Answers the backend's reasons, item conditions and resolutions, each a ReturnOptionInterface with an id and the label at the store view, plus provider_code and store_id (Hyva\Pos\Model\Data\ReturnOptions and ReturnOption). GET /V1/pos/rma/options serves it. A backend without one of the three answers an empty list for it. The register renders these options and sends the ids back on a create. It is read-only.

Each resolution also says what the register does with it, so the register never reads it from a label:

  • behavior: refund (the returned items are refunded), exchange (the register opens the exchange cart) or unsupported (the register has no flow for it, a repair for example). The constants are ReturnOptionInterface::BEHAVIOR_*.
  • is_store_credit: true on a refund resolution that refunds to store credit.

Reasons and conditions carry neither key. A resolution's JSON:

{"id": "2", "label": "Return", "behavior": "refund", "is_store_credit": false}
Provider Options Behavior
Built-in The register's six reasons, three conditions and three resolutions Refund refund, Exchange exchange, Store Credit refund with is_store_credit
Amasty The enabled reasons, conditions and resolutions at Amasty's store label From the resolution's admin title: Return and Refund refund, Exchange exchange, Store Credit refund with is_store_credit, any other unsupported. A renamed or added resolution is mapped in di.xml on Hyva\PosAmastyRma\Model\ResolutionBehaviors (titles)
Adobe Commerce The rma_item attributes' options (reason, condition, resolution) at the store view's labels; the id is the EAV option value From the option's admin label: Refund refund, Exchange exchange, Store Credit refund with is_store_credit, any other unsupported; mapped in di.xml on Hyva\PosAdobeCommerceRma\Model\ResolutionBehaviors (labels)
Aheadworks None None

getReturnEligibility

public function getReturnEligibility(int $orderId): \Hyva\Pos\Api\Data\ReturnEligibilityInterface;

Answers whether the order, and each of its lines, can be returned now (Hyva\Pos\Model\Data\ReturnEligibility and ReturnEligibilityLine): allowed or forbidden, a forbidden answer with a reason code and a sentence, the quantity a line can still return (qty_returnable, null for no limit), and the return period in days (0 meaning indefinite). GET /V1/pos/rma/eligibility/:orderId serves it. The backend's own rules decide. It is read-only.

Reason code Meaning
quantity Nothing left to return: used up, refunded, on another return, or never shipped
not_returnable The product is not returnable
outside_period The return period has passed
vendor_rule Another rule of the backend refuses it

A line the answer does not list carries no restriction of its own. Write the sentence so it reads after "... cannot be returned at the register:".

A backend whose window differs per resolution (refund within 14 days, exchange within 30) names per line the resolutions still open:

  • allowed_resolution_ids: the resolution ids (as getReturnOptions() answers them) the line can still be returned for now; empty on a forbidden line; null when the backend sets no limit per resolution, meaning every resolution it offers.
  • resolution_periods: each resolution's own period for the line (ReturnResolutionPeriodInterface: resolution_id, period_days), where the backend has one. The line's period_days stays the longest of them.
{"order_item_id": 32, "name": "Plant Stand", "outcome": "allowed", "qty_returnable": 1,
 "period_days": 30, "allowed_resolution_ids": ["1"],
 "resolution_periods": [{"resolution_id": "1", "period_days": 30}, {"resolution_id": "2", "period_days": 14}]}

The core checks it before every create. It reads the eligibility again before POST /V1/pos/rma and POST /V1/pos/rma/process create anything, and refuses a forbidden order, a forbidden line the create asks for, a quantity above qty_returnable (per row, and the rows of one order item summed), or a resolution the row may not have. The refusal is HTTP 400 with the reason in the message and parameters outcome (return_not_eligible), reason_code, period_days and order_item_id (0 for the order). There is no override. Two more reason codes are the core's own refusals, never a provider's answer:

Reason code Refused when
resolution_unsupported The resolution's behavior is unsupported
resolution_not_allowed The line lists allowed_resolution_ids and the row's resolution is not among them

A row's resolution is its resolution_id, or its label matched to an option's label or id. A label that matches no option on a line that closes nothing is left to the provider, which maps or refuses it.

Provider Eligibility
Built-in Allowed, period 0, no line restriction
Amasty Amasty's storefront rules (Allowed Order Statuses, the Return Rules) for the order's customer group, with each line's resolutions and periods; a failed read refuses
Adobe Commerce Magento's returnable lines (Enable RMA, quantity shipped less what is returned or on another return), period 0; a failed read answers allowed, and Magento's save validator still guards
Aheadworks Allowed, period 0, no line restriction

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 rows with order_item_id, qty_requested and the reason, condition and resolution.

  • Option ids win. A row may name its options by the ids getReturnOptions() answers (reason_id, condition_id, resolution_id). An id wins over a label on the same row and is used as it is, with no title matching. An id the backend does not have raises a cashier-actionable LocalizedException.
  • An unmatched label throws. A reason, condition or resolution label your backend does not recognize raises a cashier-actionable LocalizedException naming the label. Never drop it, which creates a return the cashier believes carries a reason it never recorded, and never create an option for it. The Adobe Commerce bridge refuses an unmatched label and names the configured options in the message, rather than adding the label to the merchant's attribute options.
  • Write on the caller's connection. The core runs your createReturn inside its own transaction (below). Write the vendor's return through Magento's resource models or repositories on the default connection, open no connection of your own and commit nothing, so a failure after your create rolls the vendor's return back with the core's record of it.

updateStatus

public function updateStatus(
    int $rmaId,
    string $status,
    ?string $comment = null,
    array $lineQuantities = []
): \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 or 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 returns cleanly instead of erroring, which is what makes settle retries idempotent. A return in closed or processed_closed is never moved to an open status, whatever the call: answer it unchanged or refuse with a LocalizedException, and write nothing either way.

The parked state. awaiting_refund parks a return for a handback at the counter (cash, terminal or exchange), and received after it cancels the park (POST /V1/pos/rma/:rmaId/cancel-refund). A parked return must read back as awaiting_refund from every read until the park is cancelled or the return is settled or closed, and a cancelled park must read received. Parking a parked return again changes nothing. A backend with no status that means "parked" keeps the core's marker, Hyva\Pos\Model\Rma\ParkedRefundMarker (one row per provider return):

  • park($providerCode, $returnId) on awaiting_refund (not for a closed return), and cancel($providerCode, $returnId) on received;
  • on every read, readStatus($backendStatus, $markerState), which answers awaiting_refund for an open return while parked, received once the park was cancelled and the backend reads approved, and the backend's status otherwise. stateOf() reads one return's marker state, and statesFor($providerCode, $returnIds) reads a list in one call.

The built-in provider stores the status itself. The Amasty bridge writes the marker and changes no Amasty status; the Adobe Commerce bridge walks to Magento's Approved and writes the marker beside it.

Per-line quantities. Since module 1.3.0, updateStatus takes a fourth parameter. $lineQuantities maps a return line (the item's getEntityId()) to the quantity the transition applies to on that line. The core's receive step (items_received) sends what came back of each line, and must be honored. A line received in part is not marked received as a whole: the received part moves on and the rest stays at its earlier status. The quantity the return refunds for the line (getQtyReturned(), else getQtyApproved(), summed over the line's rows) does not change, because the refund follows the requested quantity and only the restock follows the received one. A line the map leaves out moves as a whole, so an empty map is the whole-line behavior. A repeated call with the same map writes nothing. Other statuses may ignore the map.

The bridges split the line the way their vendor's admin does. Amasty: the row keeps the received quantity and is Delivered, and a new row of the order item takes the rest at the row's earlier status. Adobe Commerce: the line keeps the received quantity at Received, and a new line takes the rest at Authorized with its approved quantity set to that rest. The built-in provider records qty_received and reads the line received_partially.

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.

Declining. A provider that does not run the whole flow itself throws Hyva\Pos\Model\Rma\ReturnNotAttemptedException, before it creates, writes or refunds anything. That exception is the only one on which the core goes on to the next provider and then to its own memo-only settle, which drives createReturn() and updateStatus() and builds the credit memo directly. Never throw it once you have acted.

Any other exception stops the call, because it may come after a vendor write or a refund, and another route would refund the return a second time. The core comments on the order naming the provider and the failure, and attempts nothing else. A LocalizedException goes out with its message (HTTP 400, or the code its type maps to); anything else as HTTP 500.

The memo. A provider that implements them creates the return's credit memo through the core's Hyva\Pos\Model\Creditmemo\ReturnMemoSettlement, never through RefundOrderInterface directly and never with a credit write of its own:

  • assertResolutionPossible($orderId, $resolution) before the vendor's return exists: a refund to store credit needs an active store-credit provider that can issue refund credit, and a registered customer.
  • settle($orderId, $items, $isOnline, $resolution, $returnId) books the memo on the core settle's own sequence. $returnId is the vendor return's id, stamped on the memo as pos_rma_id. Under Magento's order mutex the memo stamped with that id is found, or created so that the active store-credit provider prepares it (see Store Credit), and every store-credit leg the memo carries is confirmed. A retried processExistingReturn therefore reuses its memo instead of booking a second one. The returned ReturnMemoOutcome carries the memo, the refund before netting (getRefundTotal()), what the memo hands back to the original tender (getHandback(), for an exchange amount), the store credit confirmed (getStoreCreditIssued()), the booked legs (getBooked()) and whether the memo was reused (isReused()).

A credit that cannot be confirmed throws, naming the memo, and the return is not closed. A gateway refund the core cannot confirm throws Hyva\Pos\Model\Creditmemo\Attempt\RefundAttemptUncertainException; let it through (catch it before LocalizedException and rethrow it) and leave the return open, never answering it as success: false. The route then answers HTTP 409 gateway_uncertain, as the settle does. Every other failure is reported in the result. The Adobe Commerce bridge's AdobeCommerceRma/Model/RmaProvider is the reference.

What the Core Does Around Your Provider

None of this changes the interface; it describes the transactions your methods run inside.

One Return per Register Sale

POST /V1/pos/rma takes pos_sale_id, the register's sale id. The core makes a create with a sale id happen once: it inserts the sale's row in hyva_pos_return_sale (unique on pos_sale_id), calls your createReturn, writes your return id on the row and commits, all in one transaction. A create that arrives with a recorded sale id calls no provider and answers that return with reused: true. A second concurrent create of one sale waits on the first one's row and fails on the unique key before it reaches the provider. A sale id recorded for another order or provider answers HTTP 409 sale_return_conflict; a create that waited past the lock timeout answers HTTP 409 sale_return_being_created.

The commit holds the return's identity only. Its initialization (with autoApprove: approve, receive, then park or settle) runs after it, under Magento's order mutex, and the sale's row records how far it got: allocated, then ready. Each step is skipped when the return shows it done, and nothing is written over a closed return. Every read serves ready (false for an allocated return), and a settle or one-call return of an allocated return finishes the initialization first or answers HTTP 409 return_not_ready. Your updateStatus therefore runs inside the order mutex's transaction during the initialization, as your createReturn runs inside the create's: write through the default connection and commit nothing of your own.

A bridge whose backend commits elsewhere (a remote RMA service, a separate database) keeps the concurrency guarantee, since the second create never reaches it. A failure between its commit and the core's leaves a vendor return with no sale row, and a retry creates another; such a bridge says so in its own documentation.

Vendor Notifications Inside a Register Transaction

A vendor that sends an email from inside these writes (the create, the initialization, a status move) would send it before the core's transaction commits, and a rollback would leave a customer notified of a return that does not exist. The core activates Hyva\Pos\Api\DeferredNotificationsInterface around those writes. While isActive() answers true, a bridge records the notification with record($kind, $dedupeKey, $payload) instead of sending it; the row commits or rolls back with the transaction. After the outermost commit, the core delivers the recorded rows through the deliverer registered for their kind. The rows live in hyva_pos_deferred_notification. The cron hyva_pos_deliver_deferred_notifications (every five minutes) delivers rows a request left pending, puts back rows a stopped worker left delivering for ten minutes, and marks a row failed after ten attempts; a failed row is kept with its last_error. A delivery claims its row with a token of its own, so two workers never send one row. The post-commit delivery never fails the operation that committed: a failure is logged and the row stays for the cron. Delivery is at least once.

To inspect and resend:

bin/magento hyva:pos:deferred-notifications list             # pending, delivering and failed rows
bin/magento hyva:pos:deferred-notifications retry <id>       # one row back to pending, attempts at 0, delivered now
bin/magento hyva:pos:deferred-notifications retry --all-failed

The deliverer implements Hyva\Pos\Api\DeferredNotificationDelivererInterface:

public function deliver(array $payload): void; // throw when it could not be sent; the row stays pending for a retry

Register it under its kind on the deliverers argument:

src/etc/di.xml
<type name="Hyva\Pos\Model\Notification\DeferredNotifications">
    <arguments>
        <argument name="deliverers" xsi:type="array">
            <item name="acme_rma_email" xsi:type="object">Acme\PosBridge\Model\RmaEmailDeliverer\Proxy</item>
        </argument>
    </arguments>
</type>

The Amasty bridge defers Amasty's RMA emails this way.

The Settle

The core runs the whole settle of a return under Magento's order mutex for the order: it reads the return again, finds the memo stamped with the return's id or creates one, confirms every wallet restoration the memo carries and moves the return to closed. A second settle of the same return waits on the lock, then answers the closed return. sales_creditmemo carries a unique (order_id, pos_rma_id) key, so a second memo for one return is refused whatever path writes it. A vendor write in your updateStatus that rolls back a nested transaction takes the whole settle back with it, memo included, and the register retries.

The core marks a return resolved only once a credit memo stamped with the return's id exists on the original order. An exchange settles the original order at exchange completion, before the exchange order is queued, so a return leaves one settlement artefact. Your updateStatus() sees the terminal transition when the memo exists.

POST /V1/pos/rma/:rmaId/settle takes notify_customer. For an original_payment, store_credit or terminal settle the core passes it to Magento's refund call, so Magento sends its own credit memo email; a bridge does nothing for it.

Refunds Through the Original Payment

A settle by original_payment on an order whose payment method can refund online goes through Magento's RefundInvoice, which calls the payment method's refund(). The core records each such refund as a refund attempt in hyva_pos_refund_attempt before the call:

State Meaning
started Written before the gateway call
booked The memo exists; the memo and this state commit together
uncertain The gateway may have refunded and no memo was booked
failed The gateway was not called, or provably did not refund; a later settle starts a new attempt

An uncertain attempt answers HTTP 409 gateway_uncertain with refund_attempt_id, refund_attempt_state and return_id, and the return stays parked until the attempt is reconciled (POST /V1/pos/rma/refund-attempts/:attemptId/reconcile) or resolved by a person who checked the payment provider (POST /V1/pos/rma/refund-attempts/:attemptId/resolve). An RMA bridge does nothing for this.

A payment integration lets the core ask its gateway about an attempt by implementing Hyva\Pos\Api\GatewayRefundLookupInterface:

public function findRefund(
    \Magento\Sales\Api\Data\OrderPaymentInterface $payment,
    \Hyva\Pos\Api\Data\RefundAttemptInterface $attempt
): \Hyva\Pos\Api\Data\GatewayRefundAnswer;

Find the refund by the attempt's id (sent as the refund's metadata or idempotency key) or by the refund reference the gateway answered, and answer new GatewayRefundAnswer($state, $reference, $reason) with GatewayRefundAnswer::REFUNDED, NOT_REFUNDED or UNKNOWN. Register it per payment method code on Hyva\Pos\Model\Creditmemo\Attempt\GatewayRefundLookupPool (lookups argument, xsi:type="object"). A method Magento marks offline needs none; any other method without one answers unknown, so its uncertain attempts wait for a manual resolution.

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.
  4. The backend's rules, not the payload, decide eligibility. Answer getReturnEligibility from your backend's own rules; the core enforces it on every create.
  5. The memo is the core's. Your provider never creates a credit memo or issues store credit outside ReturnMemoSettlement.

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 a shipped bridge (magento2-hyva-pos-aheadworks, magento2-hyva-pos-amasty or magento2-hyva-pos-adobe-commerce). Two mapping patterns transfer to most backends:

Outbound: vendor status to canonical code. Where statuses carry no state buckets, only merchant-renameable labels (Aheadworks), map by label: an alias table for the stock vendor names, then exact and substring matching against the vocabulary, with pending as the unmatched fallback so an unmatched return shows as open instead of being hidden. If your backend has real state fields, map those instead.

Inbound: POS lifecycle name to vendor transition. Two lifecycle names rarely have a literal vendor equivalent, so alias them to the nearest concept, and check the current state before touching the backend:

src/Model/RmaProvider.php (excerpt)
use Hyva\Pos\Api\Data\RmaDataInterface;
use Hyva\Pos\Api\RmaStatusVocabulary;
use Hyva\Pos\Model\Rma\ParkedRefundMarker;

private function resolveVendorStatus(string $posLifecycleName): ?int
{
    $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, array $lineQuantities = []): RmaDataInterface
{
    // Terminal stickiness: check the mapped state before touching the backend.
    $current = $this->getReturn($rmaId);
    if (in_array($current->getStatus(), RmaStatusVocabulary::TERMINAL, true)) {
        return $current;
    }
    if ($status === 'awaiting_refund') { // a backend with no parked status keeps the core's marker
        $this->parkedRefundMarker->park($this->getProviderCode(), $rmaId);
        return $this->getReturn($rmaId);
    }
    if ($status === 'items_received' && $lineQuantities !== []) {
        // split each named line: the received part moves on, the rest keeps its status
    }
    // ...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 stores only order item ids); without them the cashier sees an empty row with only a quantity. Leave refundMethod alone unless your backend natively knows the settlement channel.

A backend that does not run the one-call flow declines it:

public function processReturn(RmaCreateRequestInterface $request, string $resolution, bool $isOnline = false): ReturnProcessResultInterface
{
    throw new \Hyva\Pos\Model\Rma\ReturnNotAttemptedException(__('Acme RMA settles through the core.'));
}

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. Start a return at the register. The wizard offers your backend's reasons, conditions and resolutions, and an order outside your return period is refused with your sentence.
  2. Create the return. It appears in your vendor's backend with the requested items, the chosen options and the initial comment.
  3. Check the open-returns inbox. The new return shows up, which proves getStatus() maps to an OPEN code and the sentinel listing works.
  4. Receive part of a line. The received part moves on, the rest keeps its status, and the refund still covers the requested quantity.
  5. Park the return for a cash handback, then cancel the park. It reads awaiting_refund, then received.
  6. Settle. The core builds the credit memo; the memo lines match the returned and approved quantities and the restock follows the received count.
  7. 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 the vendor wiring through createProvider(), five required hooks and the optional ones your backend supports:

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 { /* ... */ }
}

The required hooks:

  • arrangeSentinelList(): configure your list backend so getOrderReturns(0) runs, with a no-filter expectation on your own search criteria or collection double. 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 code and every getStatusLabel() is non-empty.
  • arrangeTerminalReturnForBackwardMove(): return [provider, rmaId, expectedCanonicalStatus, backwardStatus] for an already-terminal return. The kit issues the backward move and asserts the status is unchanged.
  • arrangeUnmatchedLabelCreate(): return [provider, request, messagePattern] for a create whose label cannot map. The kit asserts a LocalizedException whose message matches the pattern.
  • arrangeProcessedReturnItems(): return the RmaItemData list of a received return. The kit asserts each item reports positive qtyReturned and qtyApproved.

The optional hooks, each skipped while it returns null:

Hook The kit asserts
arrangeClosedReturnForReopen() A closed return is never moved to an open status and nothing is written
arrangeReturnOptions() The options carry ids and labels, and every resolution a behavior and an is_store_credit
arrangeCreateWithOptionIds(), arrangeCreateWithUnknownOptionId() A create by option id reaches the backend with those ids; an unknown id is refused
arrangeReturnEligibility() The eligibility speaks its vocabulary, including the allowed resolution ids (none on a forbidden line) and the periods per resolution
arrangeResolutionWindow(), arrangeCreateWithResolutionNotAllowed() A line past one resolution's window names only those still open, and a create asking for a closed one is refused before anything is saved
arrangeParkableReturn() A repeated park reads awaiting_refund, a park after a cancel reads awaiting_refund again, and a parked return closes (on a backend whose writes persist across reads)
arrangePartialReceive() A partial receive marks only the received part and leaves the refunded quantity unchanged

Hyva\Pos\TestFramework\Rma\InMemoryParkedRefundMarker is the parked marker for a bridge's own suite. A scenario your backend cannot reach returns null from its hook; 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 check compare canonical lowercase codes. The conformance kit catches it.
  • Guard terminal stickiness before calling the vendor. Map the current status first and return early on TERMINAL, so the vendor's status change is never invoked on a closed return.
  • Refund follows requested and 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 change your quantity bookkeeping to make refunds follow received quantities.
  • Zero settlement quantities block the settle. If your backend has no per-item approved and returned fields, populate them in your updateStatus() bookkeeping when the receive transition lands.
  • Never add an option for an unmatched label. Throw with the label in the message, so the cashier can fix the input.
  • Declining is ReturnNotAttemptedException, before any write. Any other exception from processReturn stops the return with a comment on the order; it does not fall back.
  • Commit nothing in createReturn and updateStatus. Both run inside the core's transaction; a commit of your own breaks the rollback of a failed create.
  • Record a capability gap as a skip. The Aheadworks bridge's backend stores item ids and quantities only, so its conformance test returns null from arrangeUnmatchedLabelCreate() and arrangeProcessedReturnItems() with comments explaining why. Record a real gap in your backend the same way.