@molecule/api-resource-order

API resource · orders · API (Node) · v1.1.4 · Apache-2.0

Order management resource with status tracking, lifecycle transitions, cancellation, refunds, and event history.

npm install @molecule/api-resource-order

npm · Source on GitHub

How it works

@molecule/api-resource-order is an API resource: the routes, validation and storage for orders, built on the database and auth cores so it runs on whichever providers your app has bonded.

import { routes, requestHandlerMap } from '@molecule/api-resource-order'

Works with: @molecule/api-i18n, @molecule/api-resource

Live demos

Running apps built with Molecule that install this package — open the app or study the template.

Reference

Order resource for molecule.dev.

Provides order management with status tracking, lifecycle transitions, cancellation, refunds, and event history.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { routes, requestHandlerMap } from '@molecule/api-resource-order'

Type

resource

Installation

npm install @molecule/api-resource-order @molecule/api-database @molecule/api-i18n @molecule/api-logger @molecule/api-resource

API

Interfaces

Address

A physical or billing address.

interface Address {
  /** Street address line 1. */
  line1: string
  /** Street address line 2. */
  line2?: string
  /** City name. */
  city: string
  /** State or province. */
  state?: string
  /** Postal/zip code. */
  postalCode: string
  /** ISO 3166-1 alpha-2 country code. */
  country: string
}

OrderItem

A single item within an order.

interface OrderItem {
  /** Unique identifier for this order line. */
  id: string
  /** The product being purchased. */
  productId: string
  /** Optional variant (size, color, etc.). */
  variantId?: string
  /** Display name of the product. */
  name: string
  /** Unit price at time of purchase. */
  price: number
  /** Quantity ordered. */
  quantity: number
  /** Optional product image URL. */
  image?: string
}

Order

A full order record.

interface Order {
  /** Unique order identifier. */
  id: string
  /** The user who placed this order. */
  userId: string
  /** Current status of the order. */
  status: OrderStatus
  /** Items in this order. */
  items: OrderItem[]
  /** Sum of item prices × quantities before discounts. */
  subtotal: number
  /** Computed tax amount. */
  tax: number
  /** Shipping cost. */
  shipping: number
  /** Discount amount. */
  discount: number
  /** Final total: subtotal − discount + tax + shipping. */
  total: number
  /** Shipping address, if applicable. */
  shippingAddress?: Address
  /** Billing address, if applicable. */
  billingAddress?: Address
  /** Associated payment identifier. */
  paymentId?: string
  /** Order notes from the customer. */
  notes?: string
  /** When the order was created. */
  createdAt: string
  /** When the order was last updated. */
  updatedAt: string
}

CreateOrderInput

Input for creating a new order.

interface CreateOrderInput {
  /** Items to include in the order. */
  items: CreateOrderItemInput[]
  /** Shipping address. */
  shippingAddress?: Address
  /** Billing address. */
  billingAddress?: Address
  /** Associated payment identifier. */
  paymentId?: string
  /** Customer notes. */
  notes?: string
  /** Pre-computed discount amount. */
  discount?: number
  /** Pre-computed shipping cost. */
  shipping?: number
  /** Pre-computed tax amount. */
  tax?: number
}

CreateOrderItemInput

Input for a single order item during creation.

interface CreateOrderItemInput {
  /** The product to order. */
  productId: string
  /** Optional variant identifier. */
  variantId?: string
  /** Display name. */
  name: string
  /** Unit price. */
  price: number
  /** Quantity to order. */
  quantity: number
  /** Optional product image URL. */
  image?: string
}

UpdateOrderStatusInput

Input for updating an order's status.

interface UpdateOrderStatusInput {
  /** The new status. */
  status: OrderStatus
  /** Optional metadata about the status change. */
  metadata?: Record<string, unknown>
}

CancelOrderInput

Input for cancelling an order.

interface CancelOrderInput {
  /** Reason for cancellation. */
  reason?: string
}

RefundOrderInput

Input for refunding an order.

interface RefundOrderInput {
  /** Partial refund amount. If omitted, full refund is issued. */
  amount?: number
  /** Reason for the refund. */
  reason?: string
}

RefundResult

Result of a refund operation.

interface RefundResult {
  /** The order that was refunded. */
  orderId: string
  /** Amount refunded. */
  amount: number
  /** New order status after refund. */
  status: OrderStatus
  /** When the refund was processed. */
  refundedAt: string
}

OrderEvent

A historical event in an order's lifecycle.

interface OrderEvent {
  /** Unique event identifier. */
  id: string
  /** The order this event belongs to. */
  orderId: string
  /** The status at the time of this event. */
  status: OrderStatus
  /** Optional metadata about the event. */
  metadata?: Record<string, unknown>
  /** When this event occurred. */
  createdAt: string
}

OrderQuery

Query options for listing orders.

interface OrderQuery {
  /** Filter by status. */
  status?: OrderStatus
  /** Page number (1-based). */
  page?: number
  /** Number of items per page. */
  limit?: number
}

PaginatedResult

A paginated result set.

interface PaginatedResult<T> {
  /** The items in this page. */
  data: T[]
  /** Total number of items matching the query. */
  total: number
  /** Current page number (1-based). */
  page: number
  /** Number of items per page. */
  limit: number
}

OrderRow

Internal database row for an order.

interface OrderRow {
  /** Unique order identifier. */
  id: string
  /** The user who placed this order. */
  userId: string
  /** Current status. */
  status: OrderStatus
  /** Sum of item prices × quantities. */
  subtotal: number
  /** Computed tax amount. */
  tax: number
  /** Shipping cost. */
  shipping: number
  /** Discount amount. */
  discount: number
  /** Final total. */
  total: number
  /** JSON-serialized shipping address. */
  shippingAddress: string | null
  /** JSON-serialized billing address. */
  billingAddress: string | null
  /** Associated payment identifier. */
  paymentId: string | null
  /** Customer notes. */
  notes: string | null
  /** Creation timestamp. */
  createdAt: string
  /** Last modification timestamp. */
  updatedAt: string
}

OrderItemRow

Internal database row for an order item.

interface OrderItemRow {
  /** Unique order item identifier. */
  id: string
  /** Parent order identifier. */
  orderId: string
  /** The product being purchased. */
  productId: string
  /** Optional variant identifier. */
  variantId: string | null
  /** Display name. */
  name: string
  /** Unit price at time of purchase. */
  price: number
  /** Quantity ordered. */
  quantity: number
  /** Optional product image URL. */
  image: string | null
  /**
   * The line's position in the submitted item list (0-based). Rows written
   * before this column existed default to `0`; reads order by it so
   * `Order.items` keeps the order the customer submitted.
   */
  position: number
}

OrderEventRow

Internal database row for an order event.

interface OrderEventRow {
  /** Unique event identifier. */
  id: string
  /** The order this event belongs to. */
  orderId: string
  /** The status at the time of this event. */
  status: OrderStatus
  /** JSON-serialized metadata. */
  metadata: string | null
  /** When this event occurred. */
  createdAt: string
}

Types

OrderMerchantAuthorizer

Merchant-ownership predicate: returns true when userId is a merchant / seller / admin entitled to drive the given order's lifecycle (confirm, process, ship, deliver, refund, or cancel an already-progressed order). It is deliberately distinct from the buyer ownership check (orderRow.userId === userId): the order row records only the BUYER, so it has no inherent knowledge of who the seller is — only the consuming app does.

type OrderMerchantAuthorizer = (
  orderRow: OrderRow,
  userId: string,
  req?: MoleculeRequest,
) => Promise<boolean> | boolean
  • orderRow — The order being acted on.
  • userId — The authenticated user ID requesting the lifecycle op.
  • req — The originating request (optional), for apps that derive merchant identity from headers/claims rather than userId alone.

Returns: true to allow the merchant op, false to deny.

OrderStatus

Possible states of an order throughout its lifecycle.

type OrderStatus =
  'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded'

Functions

setOrderMerchantAuthorizer(authorizer)

Registers the merchant authorizer consulted by the order lifecycle handlers (refund, a merchant-state updateStatus, and cancellation of an already-progressed order) before any mutation. Until an app registers one, every merchant op is DENIED (secure by default) — the order row records only the buyer, so the consuming app MUST supply who is entitled to act as the seller/merchant on an order.

Pass null to clear a previously-registered authorizer (restores default deny).

function setOrderMerchantAuthorizer(authorizer: OrderMerchantAuthorizer | null): void
  • authorizer — The merchant predicate, or null to clear.

getOrderMerchantAuthorizer()

Returns the currently-registered merchant authorizer, or null when none has been registered.

function getOrderMerchantAuthorizer(): OrderMerchantAuthorizer | null

Returns: The registered authorizer, or null.

canDriveOrderLifecycle(orderRow, userId, req)

Default-DENY merchant gate. Returns true only when an authorizer has been registered via setOrderMerchantAuthorizer AND that authorizer allows userId to drive this order's lifecycle. When no authorizer is registered, this returns false — the merchant-only handlers respond 403.

function canDriveOrderLifecycle(
  orderRow: OrderRow,
  userId: string,
  req?: MoleculeRequest,
): Promise<boolean>
  • orderRow — The order being acted on.
  • userId — The authenticated user ID.
  • req — The originating request (optional).

Returns: true if the merchant op is allowed, otherwise false.

cancel(req, res)

Cancels an order. Only the order owner can cancel, and only from valid states.

function cancel(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with params.id and optional CancelOrderInput body.
  • res — The response object.

create(req, res)

Creates a new order from the request body.

⚠️ CLIENT-PRICE TRUST BOUNDARY — READ BEFORE WIRING TO PAYMENTS ⚠️

items[].quantity, discount, tax, and shipping come straight from the request body. For UNIT PRICES the handler now runs a SERVER-SIDE RE-PRICER: when the bonded data store exposes a products table (the catalog owned by @molecule/api-resource-product), each item's price is re-resolved from that catalog by productId (honouring a product_variants price override), subtotal/total are computed from the CATALOG prices, and the persisted order_items.price carries the server-resolved value — the client's price is discarded. Items whose product is absent from (or soft-deleted in) an EXISTING catalog are rejected with 400 rather than priced from the body.

FALLBACK: this resource is generic and owns no catalog. When the data store has no readable products table, the re-pricer steps aside and the body's price fields compute the totals — the legacy behavior, and still the trust boundary it always was. In that mode (and for discount/tax/shipping in ALL modes) this handler does NOT establish that the amounts are correct: the validation below only rejects malformed money (negative amounts, non-integer/zero quantities).

Therefore create() MUST NOT be wired directly to a payment-charging path unless either (a) a product catalog is installed (making item prices server-authoritative), or (b) the caller accepts body-priced totals.

(description truncated — read the full text in node_modules/@molecule/api-resource-order/dist/handlers/create.d.ts.)

function create(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with CreateOrderInput body.
  • res — The response object.

getHistory(req, res)

Returns the event history for an order. Only the order owner can view history.

function getHistory(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with params.id.
  • res — The response object.

list(req, res)

Lists orders for the authenticated user with pagination and optional status filter.

function list(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with optional query params: status, page, limit.
  • res — The response object.

read(req, res)

Retrieves a single order by ID. Only the order owner can read it.

function read(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with params.id.
  • res — The response object.

refund(req, res)

Issues a full or partial refund for an order.

function refund(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with params.id and optional RefundOrderInput body.
  • res — The response object.

updateStatus(req, res)

Updates an order's status with transition validation.

function updateStatus(req: MoleculeRequest, res: MoleculeResponse): Promise<void>
  • req — The request with params.id and UpdateOrderStatusInput body.
  • res — The response object.

toOrderItem(row)

Converts a database order-item row into a typed OrderItem.

function toOrderItem(row: OrderItemRow): OrderItem
  • row — The raw database row.

Returns: The deserialized order item.

toOrderEvent(row)

Converts a database order-event row into a typed OrderEvent.

function toOrderEvent(row: OrderEventRow): OrderEvent
  • row — The raw database row.

Returns: The deserialized order event.

assembleOrder(orderRow, itemRows)

Assembles a full Order object from a database order row and item rows.

The items come out in the order itemRows is given, so the caller must read them with ORDER_ITEMS_IN_ORDER — findMany's own order is the database's, not the customer's.

function assembleOrder(orderRow: OrderRow, itemRows: OrderItemRow[]): Order
  • orderRow — The order database row.
  • itemRows — The order item database rows, in line order.

Returns: The assembled order.

computeSubtotal(items)

Computes the subtotal from a list of order items (price × quantity).

function computeSubtotal(items: { price: number; quantity: number }[]): number
  • items — The order items.

Returns: The subtotal amount.

Constants

i18nRegistered

Whether i18n registration has been attempted. Always true; this module is a placeholder for symmetry with locale-bonded resources.

const i18nRegistered: true

requestHandlerMap

Handler map for the order resource routes.

const requestHandlerMap: {
  readonly create: typeof create
  readonly list: typeof list
  readonly read: typeof read
  readonly updateStatus: typeof updateStatus
  readonly cancel: typeof cancel
  readonly refund: typeof refund
  readonly getHistory: typeof getHistory
}

routes

Order resource routes. All routes require authentication. The lifecycle mutations (updateStatus/refund/cancel) additionally gate merchant-only operations behind setOrderMerchantAuthorizer (see the module SECURITY note).

const routes: readonly [
  {
    readonly method: 'post'
    readonly path: '/orders'
    readonly handler: 'create'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'get'
    readonly path: '/orders'
    readonly handler: 'list'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'get'
    readonly path: '/orders/:id'
    readonly handler: 'read'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'put'
    readonly path: '/orders/:id/status'
    readonly handler: 'updateStatus'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'post'
    readonly path: '/orders/:id/cancel'
    readonly handler: 'cancel'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'post'
    readonly path: '/orders/:id/refund'
    readonly handler: 'refund'
    readonly middlewares: readonly ['authenticate']
  },
  {
    readonly method: 'get'
    readonly path: '/orders/:id/history'
    readonly handler: 'getHistory'
    readonly middlewares: readonly ['authenticate']
  },
]

ORDER_STATUSES

Valid order statuses.

const ORDER_STATUSES: readonly OrderStatus[]

STATUS_TRANSITIONS

Status transitions: maps current status to the set of valid next statuses.

const STATUS_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]>

MERCHANT_STATES

Statuses that represent a MERCHANT-driven lifecycle stage. Transitioning an order INTO any of these is a merchant-only operation: it requires the merchant authorizer (see setOrderMerchantAuthorizer) and is never something the buyer (order owner) may self-service.

const MERCHANT_STATES: readonly OrderStatus[]

BUYER_ALLOWED_TRANSITIONS

Buyer-reachable status transitions: maps current status to the set of next statuses an ORDER OWNER (buyer) may drive themselves. A buyer may only cancel an order while it is still pending; every other lifecycle transition is merchant-only. Any transition NOT listed here requires the merchant authorizer (see setOrderMerchantAuthorizer).

const BUYER_ALLOWED_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]>

ORDER_ITEMS_IN_ORDER

The order an order's line items are read in: the order the customer submitted them in.

findMany returns rows in whatever order the database's plan produces, and where: [{ field: 'orderId', … }] hits idx_order_items_orderId — so without this, Order.items comes back in the index's order instead of the submitted one. position is written from the array index at create time; id breaks the tie for rows written before the column existed (they all defaulted to 0), which a spec asserting items[0] otherwise leaves to the database.

const ORDER_ITEMS_IN_ORDER: readonly OrderBy[]

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-database ^1.0.1
  • @molecule/api-i18n ^1.0.1
  • @molecule/api-logger ^1.0.1
  • @molecule/api-resource ^1.0.1

Runtime Dependencies

  • @molecule/api-database
  • @molecule/api-i18n
  • @molecule/api-logger
  • @molecule/api-resource

SECURITY — unit prices are SERVER-RE-PRICED when a product catalog exists; without one, create() still trusts client-supplied amounts. create() runs a server-side re-pricer: when the bonded data store exposes a products table (the catalog owned by @molecule/api-resource-product), each item's unit price is re-resolved from the catalog by productId (honouring a product_variants price override), subtotal/total are computed from the CATALOG prices, and the persisted order_items.price carries the server value — the client's price is discarded. Items whose product is missing from (or soft-deleted in) an existing catalog are rejected 400. FALLBACK: this resource is GENERIC and owns no catalog; when the data store has no readable products table, create() keeps the legacy behavior — the order total (subtotal − discount + tax + shipping) comes from the body's items[].price, discount, tax, and shipping, with only malformed money rejected (negative amounts, non-integer or < 1 quantity). discount/tax/shipping are client-supplied in BOTH modes. Any code that CHARGES off an order must still resolve those server-side (or install the product catalog so item prices are authoritative). Use the stock create() for non-charging flows (drafts, internal/admin order entry, an order that was already server-priced upstream), or wire payments only alongside an installed catalog.

Lifecycle ops (confirm/process/ship/deliver/refund, and cancelling an already-progressed order) are MERCHANT-ONLY and DENY by default until an app registers a merchant authorizer via setOrderMerchantAuthorizer — the order row records only the BUYER (userId), so it cannot know who the seller is.

Tables: src/__setup__/orders.sql creates orders, order_items, and order_events. src/__setup__/orders_position.sql adds order_items.position to a database whose table predates the column. An mlcl-scaffolded API replays __setup__/*.sql on migrate (a CREATE TABLE IF NOT EXISTS, so a table the app's own migrations already create keeps only its own columns: ALTER in the ones it lacks); anywhere else run it once — nothing at runtime creates them.

Order.items is in the order the customer submitted the lines in, kept by order_items.position (0-based). An app that reads order_items itself must order by position — where: [{ field: 'orderId', … }] is an INDEXED lookup, so findMany without orderBy returns the index's order, and an assertion on items[0] reads the wrong line otherwise. Pass orderBy: [...ORDER_ITEMS_IN_ORDER] (exported here).

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual order/checkout screens, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip. Ground every check in the REAL statuses (pending/confirmed/processing/shipped/delivered/cancelled/refunded) and the defined transitions — never a status the interface lacks:

  • Placing an order creates it pending with the exact line items submitted (productId, name, quantity) and a correctly-computed total: subtotal = the sum of price x quantity across items, and total = subtotal - discount + tax + shipping. The amount the UI shows matches that formula to the cent. NOTE the price SOURCE: with a products catalog installed it is the server-resolved catalog price (a UI displaying a stale/different price than the persisted order item is an integration bug to fix on the UI side); without a catalog it is the submitted price verbatim.
  • The fulfillment lifecycle advances ONLY through the defined transitions — pending -> confirmed -> processing -> shipped -> delivered. An illegal jump (e.g. pending -> shipped, or shipped -> pending) is rejected 409 and the order's stored status is left unchanged.
  • Cancel is honored only from a cancellable state (pending, confirmed, or processing); cancelling a shipped, delivered, cancelled, or already refunded order is rejected 409 — you cannot cancel or ship a cancelled order.
  • Refund is honored ONLY from delivered (the sole state whose transitions include refunded); refunding an unpaid/pending or a merely shipped order is rejected 409, and a refund amount <= 0 or greater than the order total is rejected 400.
  • Line items and money stay consistent across the flow: a status change never alters the stored items, subtotal, or total, and a refund records its amount (<= total) without corrupting the order total.
  • AUTHORIZATION — a user sees and mutates only their OWN orders. The list returns just the caller's orders; reading or acting on another user's order id returns 403 (or 404 when it does not exist) — guessing an id never leaks or mutates someone else's order.
  • AUTHORIZATION — no endpoint lets a normal user push an order into a privileged merchant state. Marking an order confirmed/processing/shipped/ delivered, or issuing a refund, is DENIED 403 unless a merchant authorizer (setOrderMerchantAuthorizer) approves the caller — deny by default. The only buyer-driven lifecycle action is cancelling a still-pending order.