artaza / module-rma
Returns (RMA) for Magento Open Source, which has no native RMA: the customer requests a return from their account, and an external system pulls it and pushes the decision back over REST.
Package info
github.com/martinartaza/magento_rma
Type:magento2-module
pkg:composer/artaza/module-rma
Requires
- php: >=8.1
- magento/framework: *
- magento/module-backend: *
- magento/module-customer: *
- magento/module-email: *
- magento/module-sales: *
- magento/module-store: *
- magento/module-ui: *
- magento/module-webapi: *
README
Returns (RMA) for Magento Open Source, which has no native RMA — that feature only ships with Adobe Commerce.
The customer requests a return from their account (or, as a guest, from the order lookup), and the request is exposed over REST so an external system can pull it, decide, and push the outcome back. The storefront then shows the customer where their return stands.
This module decides nothing. It records the request, mirrors a status, and displays it. Every real decision — accept or reject, whether the goods go back to sellable stock, whether a credit note is issued — belongs to whatever system you plug in. That keeps the money and the stock in one place instead of split across two.
It ships ERP-agnostic: nothing here knows about any particular back office. It was built for the Magento 2 Connector for Odoo 19, but the contract is plain REST and two dispatched events, so anything can drive it.
How it works
Two entry points, and each one dispatches an event so you can hook in without touching this module.
flowchart TB
C(["Customer — logged-in or guest"])
subgraph MAG["Magento · Artaza_Rma"]
direction TB
F["Storefront form<br/>Controller/Order · Controller/Guest"]
M["RmaManagement<br/>createRequest · updateStatus"]
DB[("artaza_rma<br/>artaza_rma_item")]
A["My Returns · admin grid<br/>badge + message"]
F --> M --> DB --> A
end
E["External system (ERP)<br/>decides"]
C -->|"1 · requests a return"| F
M -.->|"2 · artaza_rma_request_created"| E
DB -->|"3 · GET /V1/rma — cursor"| E
E -->|"4 · POST /V1/rma/{id}/status"| M
M -.->|"5 · artaza_rma_status_updated"| E
A -->|"6 · sees where it stands"| C
Loading
The loop, in order
sequenceDiagram
participant C as Customer
participant M as Magento (Artaza_Rma)
participant E as External system
C->>M: Requests a return (items + qty + reason)
Note over M: items rebuilt SERVER-SIDE from the order<br/>only the quantity comes from the customer
M->>M: status = requested, dispatch<br/>artaza_rma_request_created
loop cursor pull
E->>M: GET /V1/rma?updated_at >= cursor (ASC)
M-->>E: returns updated since the cursor
end
Note over E: the human decides:<br/>accept · reject · inspect · resolve
E->>M: POST /V1/rma/42/status<br/>{status, adminMessage, creditAmount, couponCode}
M->>M: save + dispatch artaza_rma_status_updated
M-->>C: badge + message in My Returns
Loading
Status machine
requested is the only status born in Magento. Every other transition arrives from outside — this
module records and displays them, it never decides.
stateDiagram-v2
[*] --> requested: customer asks in Magento
requested --> accepted: authorised
requested --> rejected: refused, with a reason
accepted --> in_transit: customer ships it back
in_transit --> inspection: received
inspection --> approved: passed
inspection --> fraud: seal broken / tampered
approved --> resolved_exchange: replacement sent
approved --> resolved_credit: credit + coupon
fraud --> returned: item sent back
fraud --> held: kept in quarantine
rejected --> [*]
resolved_exchange --> [*]
resolved_credit --> [*]
returned --> [*]
held --> [*]
Loading
A closed list. requested is the only one born in Magento; everything else arrives from outside.
| Status | Meaning |
|---|---|
requested |
The customer opened the return |
accepted |
Return authorised |
rejected |
Refused — carries the reason shown to the customer (terminal) |
in_transit |
The customer shipped the goods back |
inspection |
Received, under review |
approved |
Inspection passed |
fraud |
Seal broken or tampered with |
resolved_exchange |
Replacement delivered (terminal) |
resolved_credit |
Credit issued, optionally with a coupon code (terminal) |
returned |
The same item was sent back to the customer (terminal) |
held |
Kept in quarantine (terminal) |
What you get
Storefront
- A Request a return button on the order view, for logged-in customers and guests. Guest
authorisation uses Magento's own mechanism (
Sales\Helper\Guest::loadValidOrder), so no new way in is invented. - A My Returns section in the customer account: list, detail, current status and the message the operator wrote. Status is shown as a coloured badge.
- The items are rebuilt server-side from the order; only the quantity comes from the customer.
Admin
- A returns grid and a detail view.
REST
| Endpoint | Purpose |
|---|---|
GET /V1/rma?searchCriteria… |
Pull returns, typically by an updated_at cursor |
POST /V1/rma/{rmaId}/status |
Push the decision back: status, message, credit amount, coupon |
Both are guarded by the ACL resource Artaza_Rma::manage.
Events — artaza_rma_request_created and artaza_rma_status_updated, so another module can
react (send a mail, notify the ERP) without patching this one.
Install
composer require artaza/module-rma bin/magento module:enable Artaza_Rma bin/magento setup:upgrade bin/magento setup:di:compile bin/magento setup:static-content:deploy -f bin/magento cache:flush
setup:upgrade creates artaza_rma and artaza_rma_item (the child table cascades on delete,
and updated_at is indexed so a cursor pull stays cheap).
To let an external system in: System ▸ Extensions ▸ Integrations, create one, grant
Artaza_Rma::manage, activate it and use its access token as
Authorization: Bearer <token>. After changing the ACL, reauthorize the integration or its
calls keep coming back 401.
Driving it
Pull what changed since your cursor:
GET /rest/all/V1/rma?searchCriteria[filter_groups][0][filters][0][field]=updated_at &searchCriteria[filter_groups][0][filters][0][value]=2026-08-01 00:00:00 &searchCriteria[filter_groups][0][filters][0][condition_type]=gteq &searchCriteria[sortOrders][0][field]=updated_at &searchCriteria[sortOrders][0][direction]=ASC
Sort ascending by updated_at: that is what makes the cursor self-healing — a run that dies
half-way resumes from the last record it actually absorbed instead of skipping ahead.
Push the decision back:
POST /rest/all/V1/rma/42/status { "status": "resolved_credit", "resolution": "credit", "adminMessage": "Approved. Store credit available for your next purchase.", "creditAmount": 43558.79, "couponCode": "RMA42-XY7Q", "odooReference": "NC-B 0001-00000123" }
Everything except status is optional. adminMessage is what the customer reads, so a rejection
should always carry one. Re-sending the same status is idempotent — the natural key is the RMA's
increment_id.
Theme notes
The storefront templates target Hyvä. On a Luma-based theme the controllers, the model and the
REST API work unchanged, but you will want to restyle the templates. Module styles live in
view/frontend/web/css/module.css, not in the theme's build, so nothing is lost on a
static-content:deploy.
Requirements
- Magento 2.4.x (Open Source or Commerce)
- PHP 8.1+
License
OSL-3.0 — the same license as the Magento core.
Sebastian Artaza · artaza.net · martin.artaza@gmail.com