@classytic/order
v0.9.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.
Downloads
179
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.16.0 |
| @classytic/primitives | >=0.7.1 |
| @classytic/repo-core | >=0.6.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, Rma, OrderIdempotency (+ Quotation, BlanketOrder, Rfq 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, lease-idempotent), transition, cancel, updatePaymentState (overcharge-guarded), confirmPayment, addNote, addHold, resolveHold |
| fulfillment | createForOrder (idempotent via idempotencyKey), transition, addTracking, recomputeFulfillmentSummaries (counter repair) |
| orderChange | requestChange, confirm, decline |
| orderEvent | append (implements OrderEventSink) |
| RmaRepository (host-wired; model ships in the bundle) | request (idempotent via requestRef), approve, reject, markReceived, inspect, resolve (refund-sum-guarded), cancel |
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.
Idempotency (lease-based)
Pass idempotencyKey on create() and the saga runs under a claimed lease
in order_idempotency: the key is claimed BEFORE any pipeline work, the
allocated orderNumber is persisted on the record at the pivot, and the
record completes with the result. Crashed runs (expired lease) RESUME the
persisted order on retry — same identifiers, no re-executed side effects.
Concurrent duplicates get the winner's order, or a typed
IdempotencyConflictError (409) while the winner is still in flight.
Sequential retries replay the same order for 24h. The same doc-level
pattern covers fulfillment.createForOrder({ idempotencyKey }) (scoped per
order) and RmaRepository.request({ requestRef }).
Resilience
ctx.signal (AbortSignal) and ctx.retryPolicy (RetryPolicy from
@classytic/repo-core) forward to every repository round-trip — mongokit
wraps the DRIVER call only (hooks never re-run on retry). Long loops check
the signal between iterations. createOrder also asserts backend
capabilities (transactions, upsert) at boot — standalone-mongo
deployments fail fast instead of erroring on the first order.
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, order:price_bridge_fallback (PricingBridge threw —
snapshot price used), order:totals.mismatch (warning: allocations disagree
with totals). 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.
