@classytic/order
v0.4.0
Published
Framework-agnostic universal order engine — durable order records, fulfillment lifecycle, returns/exchanges, saga-based creation pipeline, bridge ports for payment/inventory/tax/loyalty. Powered by MongoDB via @classytic/mongokit.
Readme
@classytic/order
Framework-agnostic order engine on top of @classytic/mongokit + Mongoose.
Durable orders, fulfillment FSMs, returns/exchanges, 9-step creation
pipeline, bridge ports for every host integration.
Install
npm install @classytic/order \
@classytic/mongokit @classytic/primitives \
mongoose zodPeer deps (exactly what package.json declares):
| Peer | Range |
|---|---|
| @classytic/mongokit | >=3.11.0 |
| @classytic/primitives | >=0.1.0 |
| mongoose | >=9.4.1 |
| zod | >=4.0.0 |
Node >=22. ESM only.
Exports
| Subpath | Contents |
|---|---|
| @classytic/order | Engine (createOrder), repositories, domain entities, enums, bridge port types, state machines, errors, event catalog, saga primitives |
| @classytic/order/schemas/order | Zod bodies: createOrderBody, transitionBody, updatePaymentStateBody, etc. |
| @classytic/order/schemas/fulfillment | Zod bodies: createFulfillmentBody, fulfillmentTransitionBody, etc. |
| @classytic/order/schemas/order-change | Zod bodies: requestChangeBody, confirmChangeBody, etc. |
| @classytic/order/schemas/common | moneySchema, addressSchema, orderNumberParam, etc. |
Per-schema subpaths are independent entry points — importing only schemas does not pull mongoose, mongokit, or the engine factory into the bundle.
60-second integration
import mongoose from 'mongoose';
import { createOrder, repoOptionsFromCtx, type OrderContext } from '@classytic/order';
// 1. Boot the engine (owns its mongoose models on your connection).
const engine = await createOrder({
connection: mongoose.connection,
defaultCurrency: 'BDT',
bridges: { catalog: myCatalogBridge }, // only catalog is required
// multiTenant: false // if your framework handles tenant scoping
});
await engine.syncIndexes();
// 2. Build your request context (one helper per HTTP request).
const ctx: OrderContext = {
organizationId: req.headers['x-organization-id'] as string,
actorRef: req.user?.id ?? 'anonymous',
actorKind: 'user',
correlationId: req.id,
};
// 3. Place an order.
const order = await engine.repositories.order.create(
{
channel: 'web',
orderType: 'standard',
lines: [{ kind: 'sku', offerId: 'sku-123', quantity: 2 }],
customer: { email: '[email protected]', name: 'Jane' },
idempotencyKey: req.id,
} as Record<string, unknown>,
repoOptionsFromCtx(ctx),
);
// 4. Transition via FSM.
await engine.repositories.order.transition(order.orderNumber, 'confirmed', ctx);
// 5. Create a fulfillment, add tracking.
const ful = await engine.repositories.fulfillment.createForOrder(
{ orderNumber: order.orderNumber, fulfillmentType: 'physical', lines: [{ orderLineId: 'line_0', quantity: 1 }] },
ctx,
);
await engine.repositories.fulfillment.addTracking(
ful.fulfillmentNumber,
{ carrier: 'redx', trackingNumber: 'RDX-123' },
ctx,
);Anything not listed above is plain mongokit Repository<TDoc>: getById,
getByQuery, getAll, create, update, delete, restore, count,
aggregate, findAll. Use those directly.
createOrder config
| Field | Required | Notes |
|---|---|---|
| connection | yes | Mongoose connection (existing) |
| bridges.catalog | yes | Product resolver — see Bridges below |
| defaultCurrency | | ISO 4217, default USD |
| multiTenant | | true (default), false, or MultiTenantOptions. Set false if your framework handles tenant scoping already |
| tenantFieldType | | 'objectId' (default) or 'string' — type of organizationId on every model |
| eventTransport | | Any Arc-compatible EventTransport. Default: in-process bus |
| idPrefixes | | { order, fulfillment, orderChange, quotation, blanketOrder } — defaults ORD / FUL / CHG / QUO / BLK |
| idPartition | | yearly (default), monthly, daily |
| collectionPrefix | | Prepended to every physical collection name (e.g. 'commerce_') |
| autoIndex | | false for prod Atlas, true (default) for dev, or per-model override |
| orderTypes | | Extra OrderTypeHandler[] (merged with 6 built-ins) |
| fulfillmentHandlers | | Extra FulfillmentHandler[] (merged with 6 built-ins) |
| repositoryPlugins | | Per-repo extra mongokit plugins |
| modules | | Opt-in feature modules — see Modules below |
| logger | | Non-fatal error logger. Default console |
Engine surface
engine.config // frozen OrderConfig
engine.bridges // frozen OrderBridges (what you passed in)
engine.events // EventTransport — subscribe / publish
engine.models // Order, Fulfillment, OrderChange, OrderEvent (+ Quotation, BlanketOrder if enabled)
engine.repositories // order, fulfillment, orderChange, orderEvent (+ quotation, blanketOrder if enabled)
engine.modules // resolved Required<OrderModules> flags
engine.orderTypes // OrderTypeRegistry
engine.fulfillmentHandlers // FulfillmentHandlerRegistry
engine.fsm // FsmRegistry (order FSM + 6 fulfillment FSMs + change FSM)
engine.defaultCurrency // string
engine.syncIndexes() // create/update all indexes
engine.destroy() // graceful shutdownRepository domain verbs
There is no service layer. All business logic lives on repositories
that extend mongokit's Repository<TDoc> directly.
| Repository | Domain verbs (beyond mongokit base CRUD) |
|---|---|
| order | create (pipeline-aware), transition, cancel, updatePaymentState, confirmPayment, addNote, addHold, resolveHold |
| fulfillment | createForOrder, transition, addTracking |
| orderChange | requestChange, confirm, decline |
| orderEvent | append (implements OrderEventSink) |
Opt-in modules add:
| Repository | Domain verbs |
|---|---|
| quotation (when modules.quotation) | create, send, markViewed, accept, reject, expire, expireDue, convertToOrder |
| blanketOrder (when modules.blanket) | create, pause, resume, cancel, generateOne, generateDue, expireDue |
The 9-step creation pipeline
OrderRepository.create() runs a single saga with a payment pivot. Pre-pivot
steps compensate in LIFO on failure; post-pivot steps must succeed (retry,
never compensate back).
PRE-PIVOT: 1. validate 2. snapshot-lines 3. compute-totals 4. reserve-inventory
PIVOT: 5. authorize-payment
POST-PIVOT: 6. create-record 7. post-create-hooks 8. earn-loyalty 9. notifyPricingBridge.resolveLine() runs inside step 2/3, CreditPort.checkCredit()
runs after totals and before step 4; credit failures seed an on_hold order
with a credit_check_failed hold instead of failing the saga.
Collections & custom IDs
| Collection | Custom ID (via customIdPlugin) |
|---|---|
| orders | orderNumber → ORD-2026-0001 |
| order_fulfillments | fulfillmentNumber → FUL-2026-0001 |
| order_changes | changeNumber → CHG-2026-0001 |
| order_events | _id only (append-only audit log) |
| order_idempotency | internal saga state |
| quotations (opt-in) | quotationNumber → QUO-2026-0001 |
| blanket_orders (opt-in) | blanketNumber → BLK-2026-0001 |
All records are soft-deletable (softDeletePlugin({ ttlDays: 2555 }) —
7-year retention). Unique indexes are partial on deletedAt: null so
soft-deleted rows never collide with new ones. GDPR hard-delete:
repo.delete(id, { mode: 'hard' }).
Order types (6 built-in)
| Code | FSM | Default fulfillment |
|---|---|---|
| standard | Order FSM | physical |
| subscription | Order FSM | subscription |
| booking | Order FSM + no_show | booking |
| enrollment | Order FSM | digital |
| auction | Order FSM | physical |
| trade | Order FSM | physical |
Fulfillment handlers (6 built-in)
| Code | States |
|---|---|
| physical | pending → picking → packed → shipped → in_transit → delivered |
| digital | pending → granted |
| booking | pending → checked_in → in_progress → completed | no_show |
| subscription | pending → active → renewing → active | expired |
| service | pending → assigned → in_progress → completed |
| food_delivery | pending → accepted → preparing → ready → dispatched → delivered |
All FSMs also allow canceled from non-terminal states. Register extras
via createOrder({ orderTypes, fulfillmentHandlers }).
Opt-in modules
Every module defaults to false so lean hosts (simple ecom, SaaS,
course) pay zero surface-area cost for enterprise features they don't
use.
const engine = await createOrder({
connection: mongoose.connection,
bridges: { catalog },
modules: { quotation: true, blanket: true },
});| Module | Adds |
|---|---|
| quotation | Quotation entity, quotations collection, QuotationRepository, order:quotation.* events, draft → sent → viewed → accepted \| rejected \| expired → converted FSM, convertToOrder() that runs the full pipeline |
| blanket | BlanketOrder entity, blanket_orders collection, BlanketOrderRepository, order:blanket.* events, cadence-driven generateDue(now, ctx) that creates a fresh Order per due cycle |
Resolved flags are exposed on engine.modules so host code can
conditionally wire behavior.
Multi-tenant scoping
By default createOrder auto-wires multiTenantPlugin({ tenantField: 'organizationId' })
on every repository. The plugin reads organizationId from the spread
RepoOptions on every read/write.
await repo.getByQuery({ orderNumber }, repoOptionsFromCtx(ctx));
await repo.getAll({ filters: { status: 'pending' }, ...repoOptionsFromCtx(ctx) });Set multiTenant: false when your HTTP framework (Arc, etc.) enforces
tenant scoping at its own middleware layer. Repositories still write
organizationId onto the doc explicitly, so either wiring works.
tenantFieldType: 'objectId' (default) uses Schema.Types.ObjectId +
ref: 'organization' so $lookup, .populate(), and QueryParser lookups
work. Use 'string' for UUID / slug-based auth systems.
Bridge ports (host integrations)
Only catalog is required. Every other bridge degrades gracefully if
unwired. No bridge is imported from another @classytic/* package —
wiring is structural.
| Bridge | Required | Purpose |
|---|---|---|
| catalog | yes | resolveSnapshot(offerId, qty, selections, ctx) + capacity commit/release |
| revenue | | Payment lifecycle (authorize / capture / refund / escrow) |
| flow | | Inventory reservation + warehouse routing |
| pricing | | Per-line price resolution (pricelists, tiered / B2B) |
| credit | | Customer credit-worthiness check before persist |
| tax | | Tax calculation |
| loyalty | | Points earn / redeem |
| fulfillmentProvider | | Carrier labels + tracking |
| notification | | Email / SMS / push |
| booking | | Per-slot availability for the booking order type (default Mongo-backed impl auto-wires against the order collection) |
import type { OrderCatalogBridge, RevenueBridge } from '@classytic/order';
const catalog: OrderCatalogBridge = {
async resolveSnapshot(offerId, quantity, selections, ctx) { /* return LineSnapshot | null */ },
async commitCapacity() { return { status: 'committed' }; },
async releaseCapacity() { /* no-op if offers module off */ },
};
const engine = await createOrder({
connection: mongoose.connection,
bridges: { catalog /* , revenue, flow, pricing, credit, tax, loyalty, ... */ },
});All bridge port types are exported from the root entry.
Events
Events use the package: prefix — order:*, never order.* — and
are structurally compatible with @classytic/arc's DomainEvent:
{ type: 'order:confirmed', payload, meta: { id, timestamp, resource, resourceId, organizationId, correlationId } }Full string list in the ORDER_EVENTS const. Representative events:
order:created, order:confirmed, order:fulfilled, order:canceled,
order:payment.authorized, order:payment.captured,
order:payment.refunded, order:payment.state_updated,
order:fulfillment.created, order:fulfillment.transition,
order:change.requested, order:change.confirmed, order:hold.added,
order:hold.resolved, order:credit.checked, order:credit.blocked,
order:price_resolved. Opt-in modules add order:quotation.* and
order:blanket.*.
import { ORDER_EVENTS } from '@classytic/order';
await engine.events.subscribe?.(ORDER_EVENTS.ORDER_CONFIRMED, handler);
await engine.events.subscribe?.('order:*', handler); // all order events
await engine.events.subscribe?.('order:fulfillment.*', handler); // fulfillment only
await engine.events.subscribe?.('order:payment.*', handler); // payment onlyDrop in any Arc transport (Memory / Redis / Kafka / outbox) via
createOrder({ eventTransport }). Default is InProcessOrderBus —
fire-and-forget, non-durable, good for single-node. Each event also ships
a Zod schema (coreOrderEventDefinitions / orderEventDefinitions) so
hosts can register them with arc's EventRegistry directly.
Arc integration (optional)
Order repositories are Arc.RepositoryLike directly. Wire defineResource
with createAdapter(engine.models.X, engine.repositories.x):
import { defineResource } from '@classytic/arc';
import { createAdapter } from './shared/adapter.js';
import { orgScoped } from './shared/presets.js';
const engine = await ensureOrderEngine();
const adapter = createAdapter(engine.models.Order, engine.repositories.order);
export default defineResource({
name: 'order',
prefix: '/orders',
adapter,
presets: [orgScoped], // if you opted out of multiTenant on the engine
routes: [
{ method: 'POST', path: '/place', raw: true, handler: placeHandler },
{ method: 'POST', path: '/:id/action', raw: true, handler: actionHandler },
],
});Use repoOptionsFromCtx(ctx) inside handlers to forward request context
to repo calls.
Design rules
- No barrel files inside
src/. Onlysrc/index.tsre-exports. Internal folders import directly from source files. - No service wrapper layer. Repositories extend mongokit's
Repository<T>directly. Domain logic lives on the repo. - No cross-package imports. Siblings (
catalog,revenue,flow,tax,loyalty,pricing,credit,fulfillmentProvider,notification,booking) are wired via bridge ports — never imported from@classytic/*. - Zod v4. Use
z.record(z.string(), z.unknown())— neverz.record(z.unknown()).
See CLAUDE.md for the full agent / contributor rules.
Testing
npm test # integration tests via mongodb-memory-serverSee tests/helpers/setup.ts for the per-file connection pattern if you
want to copy it for your host tests.
What this package is NOT
- Not a service layer. Repositories are the API surface. If you catch
yourself writing an
OrderServicethat forwards to the repo, delete it. - Not HTTP-aware. No route wiring, no controllers. Arc, Express, Nest, or any framework can consume it.
- Not coupled to other
@classytic/*packages. Integration is through bridge contracts, never direct imports. - Not a replacement for cart. Cart → checkout handoff calls
repositories.order.create(...). Cart logic lives elsewhere.
License
MIT. See LICENSE.
Changelog
See CHANGELOG.md.
