@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-orderHow 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.tsJSDoc, 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 thanuserIdalone.
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, ornullto 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 withparams.idand optionalCancelOrderInputbody.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 withCreateOrderInputbody.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 withparams.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 withparams.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 withparams.idand optionalRefundOrderInputbody.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 withparams.idandUpdateOrderStatusInputbody.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
pendingwith the exact line items submitted (productId, name, quantity) and a correctly-computed total:subtotal= the sum of price x quantity across items, andtotal= subtotal - discount + tax + shipping. The amount the UI shows matches that formula to the cent. NOTE the price SOURCE: with aproductscatalog 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 submittedpriceverbatim. - 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 includerefunded); refunding an unpaid/pendingor a merely shipped order is rejected 409, and a refund amount <= 0 or greater than the ordertotalis 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-pendingorder.