@devx-retailos/unicommerce
v0.0.2
Published
Unicommerce WMS integration for retailOS. Provides a generic HTTP service, sale order payload helpers, pluggable UnicommerceOrderAdapter interface, and admin recovery endpoints.
Downloads
331
Readme
@devx-retailos/unicommerce
Unicommerce WMS integration for retailOS brand backends.
Provides a generic HTTP service, sale order payload helpers, a pluggable UnicommerceOrderAdapter interface, and six admin recovery endpoints.
Contents
- Overview
- Installation
- Configuration
- Registering the module
- Implementing the order adapter
- Admin API reference
- Service methods
- Payload helpers
- Inventory helpers
- Error codes
- Failure recovery runbook
Overview
Unicommerce (Uniware) is an order/warehouse management system. It IP-whitelists only the deployed backend server, so recovery operations cannot be run locally. This package solves that by exposing HTTP admin endpoints — any authenticated admin can retry failed operations via Postman or curl without SSH access.
Architecture
medusa-config.ts
└─ modules:
├─ @devx-retailos/unicommerce ← UnicommerceService (HTTP client) + 6 admin routes
└─ @/modules/uc-adapter ← Brand-specific UnicommerceOrderAdapter (your code)The package ships two kinds of admin endpoints:
| Kind | Endpoints | How it works |
|---|---|---|
| Generic | GET /sale-order/:code, POST /adjust-inventory, POST /sale-order/:code/cancel | Call UnicommerceService directly. No brand schema needed. |
| Adapter | POST /create-sale-order, POST /complete-sale-order-fulfillment, POST /process-return | Delegate to your UnicommerceOrderAdapter implementation. |
Installation
pnpm add @devx-retailos/unicommerceConfiguration
The service reads credentials from module options (preferred) or falls back to environment variables.
| Option / Env var | Required | Description |
|---|---|---|
| apiUrl / UNICOMMERCE_API_URL | yes | Base URL, e.g. https://yourstore.unicommerce.com |
| clientId / UNICOMMERCE_CLIENT_ID | yes | OAuth client ID |
| username / UNICOMMERCE_USERNAME | yes | Unicommerce admin username |
| password / UNICOMMERCE_PASSWORD | yes | Unicommerce admin password |
| channelCode / UNICOMMERCE_CHANNEL | no | Sale order channel code (default: POSX) |
| shippingCode / UNICOMMERCE_SHIPPING_CODE | no | Shipping provider code (default: SELF) |
| gatePassCodePrefix / UNICOMMERCE_GATEPASS_INITIALS | no | Prefix used to derive gate pass codes as ${prefix}-${orderCode} |
Registering the module
Add both the unicommerce module and your adapter module to medusa-config.ts:
// medusa-config.ts
import { defineConfig } from "@medusajs/framework/utils"
export default defineConfig({
modules: [
// 1. The @devx-retailos/unicommerce module (service + admin routes)
{
resolve: "@devx-retailos/unicommerce",
options: {
apiUrl: process.env.UNICOMMERCE_API_URL,
clientId: process.env.UNICOMMERCE_CLIENT_ID,
username: process.env.UNICOMMERCE_USERNAME,
password: process.env.UNICOMMERCE_PASSWORD,
channelCode: process.env.UNICOMMERCE_CHANNEL,
shippingCode: process.env.UNICOMMERCE_SHIPPING_CODE,
},
},
// 2. Your brand-specific order adapter (see next section)
{ resolve: "@/modules/unicommerce-adapter" },
],
})Implementing the order adapter
The three "adapter" endpoints (create-sale-order, complete-sale-order-fulfillment, process-return) delegate to a brand-provided module registered at the UNICOMMERCE_ORDER_ADAPTER container key. This is where your order schema knowledge lives.
Step 1 — Create the adapter module
src/modules/unicommerce-adapter/
├── index.ts
└── service.tssrc/modules/unicommerce-adapter/index.ts
import { Module } from "@medusajs/framework/utils"
import { UNICOMMERCE_ORDER_ADAPTER } from "@devx-retailos/unicommerce"
import UnicommerceAdapterService from "./service"
export default Module(UNICOMMERCE_ORDER_ADAPTER, {
service: UnicommerceAdapterService,
})src/modules/unicommerce-adapter/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import { MedusaContainer } from "@medusajs/framework/types"
import type { UnicommerceOrderAdapter } from "@devx-retailos/unicommerce"
// Import your existing subscriber / job handlers
import unicommerceSaleOrderCreationHandler from "@/subscribers/unicommerce-sale-order-creation"
import completeUnicommerceFulfillments from "@/jobs/complete-unicommerce-fulfillments"
import unicommerceReturnProcessingHandler from "@/subscribers/unicommerce-return-processing"
class UnicommerceAdapterService extends MedusaService({}) implements UnicommerceOrderAdapter {
async createSaleOrders(orderIds: string[], container: MedusaContainer): Promise<void> {
for (const orderId of orderIds) {
await unicommerceSaleOrderCreationHandler({
event: { data: { order_id: orderId } } as any,
container,
} as any)
}
}
async completeFulfillments(orderIds: string[], container: MedusaContainer): Promise<void> {
await completeUnicommerceFulfillments(container, { orderIds })
}
async processReturns(orderIds: string[], container: MedusaContainer): Promise<void> {
for (const orderId of orderIds) {
await unicommerceReturnProcessingHandler({
event: { data: { orderId } } as any,
container,
} as any)
}
}
}
export default UnicommerceAdapterServiceStep 2 — Register in medusa-config.ts
modules: [
{ resolve: "@devx-retailos/unicommerce", options: { ... } },
{ resolve: "@/modules/unicommerce-adapter" }, // ← add this
]If the adapter is not registered, the three adapter endpoints return 501 Not Implemented with a descriptive error message.
Admin API reference
All endpoints are under /admin/unicommerce/ and require the standard Medusa admin Authorization: Bearer <token> header.
GET /admin/unicommerce/sale-order/:code
Check the current status of a sale order in Unicommerce.
Path param
| Param | Description |
|---|---|
| code | Unicommerce sale order code (same as your internal order ID by default) |
Example
curl -X GET \
https://your-backend.com/admin/unicommerce/sale-order/ORDER-001 \
-H "Authorization: Bearer <token>"Success response — Unicommerce saleOrderDTO object:
{
"successful": true,
"saleOrderDTO": {
"code": "ORDER-001",
"statusCode": "FULFILLABLE",
"saleOrderItems": [
{ "code": "ORDER-001_1", "itemSku": "SKU-XYZ", "statusCode": "FULFILLABLE" }
]
}
}POST /admin/unicommerce/sale-order/:code/cancel
Cancel a sale order. Performs a pre-flight status check — returns 409 if the order is already CANCELLED, COMPLETE, or CLOSED.
Path param
| Param | Description |
|---|---|
| code | Unicommerce sale order code |
Example
curl -X POST \
https://your-backend.com/admin/unicommerce/sale-order/ORDER-001/cancel \
-H "Authorization: Bearer <token>"409 — already terminal
{
"message": "Cannot cancel sale order \"ORDER-001\" — current status is COMPLETE.",
"statusCode": "COMPLETE"
}Success response — Unicommerce cancel response.
POST /admin/unicommerce/adjust-inventory
Bulk inventory adjustment at a facility. Supports ADD, REMOVE, REPLACE, TRANSFER.
Request body
{
facilityCode: string // required — Unicommerce facility code
inventoryAdjustments: Array<{
itemSKU: string // required
quantity: number // required
shelfCode: string // required
adjustmentType: "ADD" | "REMOVE" | "REPLACE" | "TRANSFER" // required
facilityCode: string // required (same as top-level)
inventoryType?: "GOOD_INVENTORY" | "BAD_INVENTORY" | "QC_REJECTED" | "VIRTUAL_INVENTORY"
remarks?: string
transferToShelfCode?: string // required when adjustmentType = "TRANSFER"
sla?: string
}>
forceAllocate?: boolean // default: false
}Example — add inventory for an UNFULFILLABLE backlog
curl -X POST \
https://your-backend.com/admin/unicommerce/adjust-inventory \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"facilityCode": "POSX_Central",
"inventoryAdjustments": [
{
"itemSKU": "SKU-XYZ",
"quantity": 50,
"shelfCode": "DEFAULT",
"adjustmentType": "ADD",
"facilityCode": "POSX_Central",
"inventoryType": "GOOD_INVENTORY",
"remarks": "Manual adjustment for fulfillment backlog"
}
]
}'POST /admin/unicommerce/create-sale-order
Retry sale order creation for orders stuck in FAILED_SALE_ORDER. Triggers your adapter's createSaleOrders method asynchronously.
Request body
{
"order_ids": ["order-uuid-1", "order-uuid-2"]
}Response — 202 Accepted (fire-and-forget)
{
"message": "Sale order creation triggered for 2 order(s).",
"order_ids": ["order-uuid-1", "order-uuid-2"]
}When to use
- Order stuck at
warehouse_status = FAILED_SALE_ORDER - Fix the root cause first (bad address format, SKU not in Unicommerce item master), then call this endpoint to retry
POST /admin/unicommerce/complete-sale-order-fulfillment
Run the fulfillment pipeline (invoice → manifest → deliver) for specific orders. Triggers your adapter's completeFulfillments method asynchronously.
Request body
{
"order_ids": ["order-uuid-1", "order-uuid-2"]
}Response — 202 Accepted
{
"message": "Fulfillment pipeline triggered for 2 order(s).",
"order_ids": ["order-uuid-1", "order-uuid-2"]
}When to use
- Orders stuck at
UNFULFILLABLE— after callingadjust-inventoryto add stock, Unicommerce auto-marks themFULFILLABLE, then call this to complete dispatch - Bulk backlog after a cron gap (e.g. 4,500+ orders missed by rolling time window)
POST /admin/unicommerce/process-return
Trigger the reverse-logistics flow for returned / exchanged orders. Triggers your adapter's processReturns method asynchronously.
Request body
{
"order_ids": ["order-uuid-1", "order-uuid-2"]
}Response — 202 Accepted
{
"message": "Return processing triggered for 2 order(s).",
"order_ids": ["order-uuid-1", "order-uuid-2"]
}When to use
- Return subscriber fired but failed, and the physical return happened without Unicommerce inventory being updated
- Order was never dispatched in Unicommerce first — fix that, then call this
Service methods
Resolve UnicommerceService from the container (container.resolve(UNICOMMERCE_MODULE)) to call Unicommerce directly. All methods authenticate lazily and retry once on an expired token. Beyond the sale-order, fulfillment, return, and adjustInventoryBulk methods used by the admin routes, the service covers the protocol surface every brand POS needs:
Gate pass
Inbound-goods ("purchase gate pass") operations. createGatePass derives the code from gatePassCodePrefix + orderCode and is idempotent: if Unicommerce reports the code already exists (error 100222), the existing gate pass is fetched and returned instead of failing.
import { UNICOMMERCE_MODULE, UnicommerceService } from "@devx-retailos/unicommerce"
const uc = container.resolve<UnicommerceService>(UNICOMMERCE_MODULE)
const gatePass = await uc.createGatePass({ orderCode: "ORDER-1", partyCode: "VENDOR-1", facilityId })
await uc.addItemToGatePass({ gatePassCode: "POSX-ORDER-1", itemCode: "IT-1", facilityId })
await uc.removeItemFromGatePass({ gatePassCode: "POSX-ORDER-1", itemCode: "IT-1", facilityId })
await uc.getGatePass({ gatePassCodes: ["POSX-ORDER-1"], facilityId })
await uc.completeGatePass({ gatePassCode: "POSX-ORDER-1", facilityId })
await uc.discardGatePass({ gatePassCode: "POSX-ORDER-1", facilityId })Catalog lookup
await uc.getItemByItemCode({ itemCode: "IT-1", facilityId }) // by warehouse item code
await uc.getItemTypeBySkuCode({ skuCode: "SKU-A", facilityId }) // by catalog SKU codeInventory export
pullInventoryExport runs Unicommerce's async export-job flow end to end (create job → poll until COMPLETE → download + parse CSV) and returns the parsed rows. Failures are returned as { successful: false, error } rather than thrown, so a multi-facility sync can continue past one bad facility.
const result = await uc.pullInventoryExport({
facilityId,
updatedSince: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(),
})
// result.data → Array<Record<columnHeader, value>>For the live, per-bucket snapshot use getInventorySnapshot({ updatedSinceInMinutes, facilityId }) and normalize it with the inventory helpers.
Fulfillment pipeline
fulfillSaleOrder runs the per-order pipeline — fetch the sale order → verify every item is FULFILLABLE → create an invoice/label per shipping package → close the manifest → mark delivered. It returns a structured per-stage result instead of throwing, so you map the outcome onto your own order status and persist it. channel / shippingProviderCode fall back to the configured module options.
const result = await uc.fulfillSaleOrder({ saleOrderCode: "ORDER-1" })
if (result.successful) {
// result.stage === "COMPLETE", result.shippingManifestCode available
} else {
// result.stage ∈ "FETCH" | "FULFILLABILITY" | "INVOICE" | "MANIFEST" | "DELIVER"
// result.error, result.unfulfillableItems (when stage === "FULFILLABILITY")
}Scheduling, order selection (time windows, status filters), and persistence of the result stay in your backend — this method owns only the Unicommerce protocol sequence.
Payload helpers
Exported from @devx-retailos/unicommerce/helpers. Use these in your adapter / subscriber to build the Unicommerce sale order payload.
transformAddressForUnicommerce(address, email)
Converts your internal address object to the Unicommerce address format.
import { transformAddressForUnicommerce } from "@devx-retailos/unicommerce/helpers"
const ucAddress = transformAddressForUnicommerce(
{
id: "addr-001",
firstName: "Priya",
lastName: "Sharma",
address1: "12 MG Road",
address2: "Block B",
city: "Bengaluru",
state: "Karnataka",
country: "India",
pinCode: "560001",
phone: "9876543210",
},
"[email protected]",
)buildSaleOrderPayload(config)
Assembles the complete Unicommerce POST /saleOrder/create request body.
import {
buildSaleOrderPayload,
transformAddressForUnicommerce,
} from "@devx-retailos/unicommerce/helpers"
const payload = buildSaleOrderPayload({
orderId: orderDetail.id,
orderCode: orderDetail.order_code,
orderCreatedAt: orderDetail.created_at,
customerId: orderDetail.customer_id,
customerName: `${orderDetail.first_name} ${orderDetail.last_name}`.trim(),
gstNumber: orderDetail.gst_number ?? "",
email: orderDetail.email ?? "",
phone: orderDetail.phone ?? "",
facilityCode: storeDetails.warehouse_mapping_id,
products: orderDetail.order_product_details.map((p) => ({
id: p.id,
variant_sku: p.variant_sku,
quantity: p.quantity,
unit_price: Number(p.unit_price),
total_discount: Number(p.total_discount),
})),
addresses: [billingUcAddress, shippingUcAddress],
billingAddressId: billingAddress.id,
shippingAddressId: shippingAddress.id,
totalDiscount: Number(orderDetail.total_discount ?? 0),
totalPrepaidAmount: Number(orderDetail.total_with_discount),
customFields: [{ name: "STN", value: "RETAIL" }],
channelCode: process.env.UNICOMMERCE_CHANNEL,
})createSaleOrderItems(products, facilityCode)
Splits products with quantity > 1 into individual Unicommerce line items with chronological codes (product_id_1, product_id_2, …).
import { createSaleOrderItems } from "@devx-retailos/unicommerce/helpers"
const items = createSaleOrderItems(products, "POSX_Central")Inventory helpers
Exported from @devx-retailos/unicommerce/helpers. These turn raw Unicommerce inventory responses into a brand-agnostic shape. They are pure functions — no storage opinion. Persist the result into whatever inventory table your backend owns.
normalizeInventorySnapshot(rows, facilityCode)
Maps the raw inventorySnapshots rows from getInventorySnapshot(...) to NormalizedInventory[]. All ten Unicommerce stock buckets are mapped to snake_case fields and missing buckets default to 0.
import { normalizeInventorySnapshot } from "@devx-retailos/unicommerce/helpers"
const snapshot = await uc.getInventorySnapshot({ updatedSinceInMinutes: 1440, facilityId })
const rows = normalizeInventorySnapshot(snapshot.inventorySnapshots ?? [], facilityId)
// rows[0] → { sku, facility_code, inventory, open_sale, open_purchase, putaway_pending,
// inventory_blocked, pending_stock_transfer, vendor_inventory, virtual_inventory,
// pending_inventory_assessment, bad_inventory }mergeInventoryExportMetadata(normalized, exportRows, columns?)
The JSON snapshot omits bad-inventory for most facilities; the CSV export carries it. This overlays bad_inventory onto the normalized rows, matched by SKU (case-insensitive). Column headers default to Unicommerce's export (Item SkuCode, Bad Inventory) and can be overridden.
import { mergeInventoryExportMetadata } from "@devx-retailos/unicommerce/helpers"
const { data: exportRows } = await uc.pullInventoryExport({ facilityId, updatedSince })
const merged = mergeInventoryExportMetadata(rows, exportRows)normalizeSaleOrderStatus(rawStatusCode)
Unicommerce returns a wide, inconsistent set of statusCode values across sale orders and their items. This collapses the known Unicommerce vocabulary into the NormalizedSaleOrderStatus enum so you branch on a stable value instead of raw strings. Unrecognized codes return UNKNOWN.
import { normalizeSaleOrderStatus, NormalizedSaleOrderStatus } from "@devx-retailos/unicommerce"
const status = normalizeSaleOrderStatus(item.statusCode) // e.g. "MANIFESTED" → READY_TO_SHIP
if (status === NormalizedSaleOrderStatus.DELIVERED) { /* ... */ }
normalizeSaleOrderStatusis also available from@devx-retailos/unicommerce/helpers; theNormalizedSaleOrderStatusenum is exported from the package root and@devx-retailos/unicommerce/types.
The enum covers Unicommerce protocol statuses only. Brand pipeline/failure states (
FAILED_INVOICE,FAILED_MANIFEST,ERROR, …) are your backend's own order status — keep them out of this mapping.
Error codes
All errors from this package extend RetailOSError and have a stable code field.
| Code | Class | Description |
|---|---|---|
| RETAILOS_UNICOMMERCE_AUTH_FAILED | UnicommerceAuthError | OAuth token fetch failed |
| RETAILOS_UNICOMMERCE_API_ERROR | UnicommerceApiError | A Unicommerce REST call returned an error or non-200 |
| RETAILOS_UNICOMMERCE_ADAPTER_NOT_REGISTERED | UnicommerceAdapterNotRegisteredError | UNICOMMERCE_ORDER_ADAPTER key not found in the container |
import { UnicommerceApiError } from "@devx-retailos/unicommerce"
try {
await unicommerceService.createSaleOrder(payload)
} catch (err) {
if (err instanceof UnicommerceApiError) {
console.log(err.code) // "RETAILOS_UNICOMMERCE_API_ERROR"
console.log(err.operation) // "createSaleOrder"
console.log(err.details) // raw Unicommerce errors array
}
}Failure recovery runbook
Case 1 — FAILED_SALE_ORDER (bad address or SKU not in item master)
1. Check CloudWatch logs for the specific error (address validation / item not found)
2. If address: fix the order address in the POS system
If SKU: create the item in Unicommerce item master via the Unicommerce portal
3. POST /admin/unicommerce/create-sale-order
Body: { "order_ids": ["<order_id>"] }Case 2 — UNFULFILLABLE (0 inventory in Unicommerce)
1. POST /admin/unicommerce/adjust-inventory
Body: { "facilityCode": "<facility>", "inventoryAdjustments": [{ "itemSKU": "...", "quantity": N, "adjustmentType": "ADD", ... }] }
2. Unicommerce automatically marks affected orders as FULFILLABLE
3. POST /admin/unicommerce/complete-sale-order-fulfillment
Body: { "order_ids": ["<order_id_1>", "<order_id_2>", ...] }Case 3 — Cron window gap (bulk backlog)
1. POST /admin/unicommerce/complete-sale-order-fulfillment
Body: { "order_ids": ["id1", "id2", ... all affected order IDs] }Case 4 — FAILED_RETURN (return subscriber failed)
1. Verify the sale order exists and was dispatched in Unicommerce:
GET /admin/unicommerce/sale-order/<code>
2. If not dispatched: run Case 2 / Case 3 first
3. POST /admin/unicommerce/process-return
Body: { "order_ids": ["<order_id>"] }Case 5 — Cancel a stuck order
1. GET /admin/unicommerce/sale-order/<code> ← check current status
2. POST /admin/unicommerce/sale-order/<code>/cancel
Returns 409 if already CANCELLED / COMPLETE