@devx-retailos/order
v5.0.1
Published
Order module for retailOS. Manages POS orders, line items, and payments with built-in discount validation.
Downloads
938
Keywords
Readme
@devx-retailos/order
POS order management for Medusa v2: orders, line items, payments, discount validation, advance orders with deposits and fulfillment stages, returns & policy-driven refunds, and daily / day-close reporting.
Part of retailOS, a Medusa v2 SDK for offline-store POS systems. Each @devx-retailos/* package is an independently installable Medusa plugin; a brand backend composes the ones it needs in medusa-config.ts.
Installation
npm install @devx-retailos/orderPeer dependencies: @medusajs/framework and @medusajs/medusa ^2.15.0. @devx-retailos/discount is an optional peer — required only if you place orders with discount_id or coupon_code.
Setup
// medusa-config.ts
module.exports = defineConfig({
plugins: [
{ resolve: "@devx-retailos/rbac", options: {} },
{ resolve: "@devx-retailos/order", options: {} },
],
})The module registers under the key retailos_order (exported as ORDER_MODULE) and registers its permission keys with @devx-retailos/rbac at boot.
Options
| Option | Type | Default | Purpose |
| --- | --- | --- | --- |
| refundPolicy | RefundPolicyConfig | ≤10d → 100%, ≤30d → 50%, beyond → ineligible; defective goods full refund | Time-based refund limitation used by the return flow. |
| refundMatrix | RefundMatrix | { default: "ORIGINAL_TENDER" } | Decides the refund form (original tender / store credit / exchange-only), with optional per-category overrides. |
Both are read by the default refund-policy resolver; a brand can instead register a dynamic resolver (see Extension points).
Usage
import { ORDER_MODULE, type OrderModuleService } from "@devx-retailos/order"
const orders: OrderModuleService = container.resolve(ORDER_MODULE)
// Place a standard order (discount validated via @devx-retailos/discount if provided)
const { order } = await orders.placeOrder({
organization_id: "org_...",
store_id: "store_...", // the PHYSICAL retail store
cart_id: "cart_...", // the cart this order came from
ecommerce_store_name: "STORE_A", // which ecommerce store it sells through
currency: "INR",
line_items: [{ id: "tmp-1", name: "T-Shirt", quantity: 2, unit_price: 499 }],
coupon_code: "WELCOME10",
placed_by_employee_id: "emp_...",
})
// Confirm: records the payment, sets status "confirmed", persists a permanent
// tax invoice (via @devx-retailos/invoice), records a stock issue (via
// @devx-retailos/inventory), and pushes the order to the registered ecommerce
// adapter (status becomes "synced_to_ecommerce"). All side modules are optional.
const result = await orders.confirmOrder(order.id, {
payment: { method: "cash", amount: 998 },
})Selling through more than one ecommerce store
A product id issued by one ecommerce store is meaningless in another, so a basket spanning two
stores cannot become a single order there. Call placeOrder once per store, passing the same
cart_id and invoice_code with a different ecommerce_store_name:
const shared = { cart_id: "cart_...", invoice_code: "INV-001", organization_id, store_id, currency: "INR" }
const a = await orders.placeOrder({ ...shared, ecommerce_store_name: "STORE_A", line_items: storeALines })
const b = await orders.placeOrder({ ...shared, ecommerce_store_name: "STORE_B", line_items: storeBLines })(cart_id, ecommerce_store_name) is unique among active orders, so a retried or duplicated
placement cannot create a second order for a store that already has one. Group the pieces back
together with listOrders({ cart_id }).
invoice_code is deliberately not part of that key — adding a column to a unique index only
weakens it, and the caller supplies the invoice code. Sharing one invoice across the pieces is the
caller's job; the index guarantees there is at most one order per store regardless.
Splitting is the caller's job. This module does not decide which lines belong to which store, or
how whole-bill figures divide across them — that is brand logic, and it stays in the brand.
A single-store deployment simply passes the same ecommerce_store_name every time.
ecommerce_store_name is nullable on the model, because orders written before multi-store support
predate the concept; it is required on PlaceOrderInput so every new order states it explicitly.
Advance orders
Passing advance_deposit_amount to placeOrder creates an advance order: the deposit is recorded, balance_due is tracked, and the order enters the first stage of its stage provider. Advance orders cannot use confirmOrder — collect the remainder with collectBalance:
const { order } = await orders.placeOrder({
// ...as above
advance_deposit_amount: 500,
promised_fulfillment_date: new Date("2026-07-01"),
})
await orders.transitionStage(order.id, "processing", { changed_by_employee_id: "emp_..." })
await orders.collectBalance(order.id, [{ method: "card", amount: 1500 }])listAdvanceOrders({ organization_id, fulfillment_stage?, promised_before?, promised_after? }) queries open advance orders.
Which coupons applied — *_discount_code is deprecated
online_discount_code and store_discount_code are deprecated as a way to learn which coupons
applied, and will be removed in a future major. One column cannot name two coupons: an order
carrying more than one records the first code against the summed amount, so a report
grouping by it charges that coupon with liability it never earned and loses the others entirely.
Use listAppliedCoupons() from @devx-retailos/discount — one entry per coupon. It takes a
fallback so orders written before that ledger existed still answer:
import { DISCOUNT_MODULE, type DiscountModuleService } from "@devx-retailos/discount"
const discounts = container.resolve<DiscountModuleService>(DISCOUNT_MODULE)
const applied = await discounts.listAppliedCoupons({
order_id: order.id,
fallback: {
online_discount_code: order.online_discount_code,
online_discount_amount: order.online_discount_amount,
store_discount_code: order.store_discount_code,
store_discount_amount: order.store_discount_amount,
},
})The two *_amount columns are not deprecated. They are the complete totals — nothing is missing
from them — and grand_total and tax allocation derive from them. They stay.
All four columns are still written, unchanged. PlaceOrderInput still takes all four and still
must: they are how the amounts reach the order. Nothing breaks yet.
Reports
const daily = await orders.dailyReport({ organization_id: "org_...", store_id: "store_...", date: "2026-06-11" })
// → order_count, subtotal, discount_total, tax_total, grand_total, payment_methods[]
const close = await orders.dayCloseReport({ organization_id: "org_...", store_id: "store_...", date: "2026-06-11" })
// → daily report + closed_at, opening_total, closing_total, variancetotalsForStore({ organization_id, store_id?, status? }) returns simple revenue aggregates.
Returns & refunds
A return is a new document — it never edits the original order. Refunds are based on what the customer actually paid per line (line subtotal − line discount − its prorated share of any order-level discount + tax), never on today's shelf price. A time-based policy then scales the refundable amount, and a matrix decides the form.
// 1. Preview — compute refundable amounts + eligibility without persisting
const preview = await orders.previewReturn({
original_order_id: "ord_...",
returning_store_id: "store_...", // may differ from the selling store (cross-store)
lines: [{ original_line_item_id: "oli_...", quantity: 1 }],
})
// → { refund_total, refund_multiplier_percent, refund_form, days_since_purchase, eligible, ... }
// 2. Create — validates eligibility, atomically claims returned quantity
// (cumulative returns can never exceed what was purchased), persists the return
const { return: ret } = await orders.createReturn({
original_order_id: "ord_...",
returning_store_id: "store_...",
lines: [{ original_line_item_id: "oli_...", quantity: 1 }],
processed_by_employee_id: "emp_...",
})
// 3. Complete — quality check → mirror to Unicommerce → execute the refund → close
await orders.completeReturn(ret.id, {
quality_results: [{ original_line_item_id: "oli_...", quality_result: "GOOD" }],
})The refund is executed by form: STORE_CREDIT issues a @devx-retailos/gift-voucher voucher; ORIGINAL_TENDER delegates to a registered ReturnRefundExecutor (or records a pending refund payment if none is registered); EXCHANGE_ONLY records no monetary refund. On completion the return also (best-effort, when the relevant module is installed) mirrors to Unicommerce, persists a credit note via @devx-retailos/invoice, records a stock receive via @devx-retailos/inventory, and — for a cross-store return — writes an inter-store settlement (store Y refunded on store X's behalf). Missing sibling modules degrade gracefully — a refund is never lost because a mirror is unavailable.
Exchanges
An exchange is one return leg plus one new sale under a single id, committed as a saga:
const { exchange, return: ret, new_order } = await orders.createExchange({
original_order_id: "ord_...",
returning_store_id: "store_...",
return_lines: [{ original_line_item_id: "oli_...", quantity: 1, quality_result: "GOOD" }],
new_sale: { // the new item at today's price — a fresh order + invoice
organization_id: "org_...",
store_id: "store_...",
cart_id: "cart_...", // new_sale is a PlaceOrderInput — both are required
ecommerce_store_name: "STORE_A",
currency: "INR",
line_items: [{ id: "tmp-1", name: "New Tee", quantity: 1, unit_price: 2500 }],
},
courier_pickup: false, // true → raise a Unicommerce reverse pickup for the old item
processed_by_employee_id: "emp_...",
})
// settle = new_invoice − credit_note: positive → customer pays the difference,
// negative → surplus issued as store credit.The old item is returned as EXCHANGE_ONLY (restocked + credit note, no cash refund) and the new item goes out as a normal order — with its own fresh invoice and a Unicommerce sale order. If the sale leg fails before the return completes, the new order is cancelled and the return is rolled back — you never end up with half an exchange. For a courier exchange (courier_pickup: true), the old item is collected via a Unicommerce reverse pickup (the brand's UC adapter must implement createReversePickup) rather than a counter drop-off.
Inter-store settlement
Cross-store returns and exchanges write an inter-store settlement entry (debtor = selling store, creditor = returning store). List open entries with GET /admin/retailos/settlements and square them up with settleInterStore(id) / POST /admin/retailos/settlements/:id/settle.
Extension points
OrderEcommerceAdapter
Pushes confirmed POS orders to an ecommerce platform (Shopify, WooCommerce, …). Register an implementation in the container under the ORDER_ECOMMERCE_ADAPTER key ("order_ecommerce_adapter"). If no adapter is registered, confirmed orders stay at status "confirmed" and can be retried later; sync failures are recorded in the order's metadata.
import type { OrderEcommerceAdapter, NormalizedOrderForEcommerce } from "@devx-retailos/order"
const adapter: OrderEcommerceAdapter = {
async createOrder(order: NormalizedOrderForEcommerce) {
return { ecommerce_order_id: "ext_123" }
},
async cancelOrder(ecommerce_order_id: string) {},
}AdvanceOrderStageProvider
Defines the fulfillment stages of advance orders. The built-in DefaultStageProvider ships stages pending → processing → ready → fulfilled / cancelled (fulfilled and cancelled are terminal).
import type { AdvanceOrderStageProvider } from "@devx-retailos/order"
const madeToOrder: AdvanceOrderStageProvider = {
name: "made-to-order",
stages: () => ["pending", "in_production", "qc", "ready", "fulfilled", "cancelled"],
isTransitionAllowed: (from, to) => from !== "fulfilled" && from !== "cancelled",
}
orders.registerStageProvider(madeToOrder)
// then: placeOrder({ ..., advance_deposit_amount: 500, stage_provider: "made-to-order" })RefundPolicyResolver
Decides the time tiers ("how much") and the refund matrix ("in what form") for a return. The default resolver reads the refundPolicy / refundMatrix plugin options. Register a custom resolver to vary policy per store, category, or product:
import type { RefundPolicyResolver } from "@devx-retailos/order"
const resolver: RefundPolicyResolver = {
name: "seasonal",
resolve: (ctx) => ({ policy: {/* tiers */}, matrix: { default: "STORE_CREDIT" } }),
}
orders.registerRefundPolicyResolver(resolver)ReturnRefundExecutor
Executes an ORIGINAL_TENDER refund against the payment gateway. Register an implementation under the RETURN_REFUND_EXECUTOR key — typically wrapping @devx-retailos/payments refund with your own PaymentCart context. If none is registered, completeReturn records a pending refund payment for the brand to reconcile out of band.
import type { ReturnRefundExecutor } from "@devx-retailos/order"
const executor: ReturnRefundExecutor = {
async executeRefund(ctx) {
// ctx: { return_id, original_order_id, amount, currency, store_id, ... }
return { status: "succeeded", reference: "gw_txn_123" }
},
}
// container.register(RETURN_REFUND_EXECUTOR, asValue(executor))Permissions
Registered via @devx-retailos/rbac (also exported as ORDER_PERMISSIONS from @devx-retailos/order/permissions):
| Key | Description |
| --- | --- |
| order.read | View orders and order details |
| order.create | Place new orders |
| order.update | Update order status, notes, and metadata |
| order.cancel | Cancel orders |
| order.refund | Mark orders as refunded |
| order.void | Void orders |
| order.return | Create and complete returns against orders |
| order.return.approve | Approve returns outside the standard refund policy |
| order.exchange | Create exchanges (return plus new sale) |
| order.settlement | View and settle inter-store settlement ledger entries |
| order.report | View order totals and revenue aggregates |
| order.discount_apply | Place orders with a discount or coupon attached |
| order.advance_stage | Transition an advance order's fulfillment stage |
| order.collect_balance | Capture balance payment on an advance order |
| order.invoice | Generate and download GST-compliant PDF invoices |
| order.report.daily | View daily sales report and revenue aggregates |
| order.report.day-close | Run the end-of-day Z-report and close the trading session |
API routes
All routes live under the authenticated Medusa admin scope. The report routes enforce their permission via @devx-retailos/rbac.
| Method | Path | Purpose | Permission |
| --- | --- | --- | --- |
| GET | /admin/retailos/orders | List orders (filterable) | — |
| POST | /admin/retailos/orders | Place an order (standard or advance) | — |
| GET | /admin/retailos/orders/:id | Retrieve order + line items + payments | — |
| POST | /admin/retailos/orders/:id | Update status / notes / metadata | — |
| DELETE | /admin/retailos/orders/:id | Cancel an order | — |
| POST | /admin/retailos/orders/:id/confirm | Confirm + push to ecommerce adapter | — |
| POST | /admin/retailos/orders/:id/returns/preview | Preview refundable amounts + eligibility | order.return |
| POST | /admin/retailos/orders/:id/returns | Create a return | order.return |
| POST | /admin/retailos/orders/:id/returns/:returnId/complete | QC → mirror → refund → close | order.return |
| POST | /admin/retailos/orders/:id/exchanges | Create an exchange (return + new sale) | order.exchange |
| GET | /admin/retailos/settlements | List inter-store settlement entries | order.settlement |
| POST | /admin/retailos/settlements/:id/settle | Mark a settlement squared up | order.settlement |
| POST | /admin/retailos/orders/:id/balance | Collect balance on an advance order | order.collect_balance |
| PUT | /admin/retailos/orders/:id/stage | Transition an advance order's stage | order.advance_stage |
| GET | /admin/retailos/orders/totals | Revenue aggregates per org/store | — |
| GET | /admin/retailos/orders/reports/daily | Daily sales report | order.report.daily |
| POST | /admin/retailos/orders/reports/day-close | End-of-day close report | order.report.day-close |
Errors
All extend RetailOSError from @devx-retailos/core; switch on err.code:
RETAILOS_ORDER_NOT_FOUNDRETAILOS_ORDER_INVALID_STATUSRETAILOS_ORDER_DEPOSIT_EXCEEDS_TOTALRETAILOS_ORDER_INVALID_STAGE_TRANSITIONRETAILOS_ORDER_NOT_ADVANCERETAILOS_ORDER_CONFIRM_BLOCKED_FOR_ADVANCERETAILOS_ORDER_OVER_RETURNRETAILOS_ORDER_RETURN_NOT_ELIGIBLERETAILOS_ORDER_RETURN_NOT_FOUNDRETAILOS_ORDER_RETURN_INVALID_STATUS
Related packages
@devx-retailos/core— shared types,RetailOSError,Logger, permission registry.@devx-retailos/rbac— roles and permission checks the order routes rely on.@devx-retailos/discount— discount/coupon validation used byplaceOrder(optional).@devx-retailos/payments— pluggable payment adapters, split tenders, refunds.@devx-retailos/gift-voucher— store-credit vouchers issued onSTORE_CREDITrefunds (optional).@devx-retailos/invoice— GST-compliant PDF invoices generated from these orders.@devx-retailos/products— catalog mirror that supplies product/variant data.
License
MIT
