# order-state-machine

[![npm version](https://img.shields.io/npm/v/order-state-machine.svg)](https://www.npmjs.com/package/order-state-machine)
[![license](https://img.shields.io/npm/l/order-state-machine.svg)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-39%20passing-brightgreen)](#)
[![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](#)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](#)

A generic, fully-typed state machine for food delivery, logistics, ride-hailing, e-commerce, or any order lifecycle. Bring your own states and events, or use the built-in delivery defaults. Zero runtime dependencies.

---

## Installation

```bash
npm install order-state-machine
# or
yarn add order-state-machine
```

---

## Two ways to use it

### Option A: Built-in delivery defaults (zero config)

```typescript
import { DeliveryStateMachine } from "order-state-machine";

const order = new DeliveryStateMachine({ orderId: "ord_001" });

await order.dispatch("CONFIRM");
await order.dispatch("START_PREPARING");
await order.dispatch("MARK_READY");
await order.dispatch("PICKUP");
await order.dispatch("START_TRANSIT");
await order.dispatch("DELIVER");

console.log(order.state);       // "DELIVERED"
console.log(order.isTerminal()); // true
```

### Option B: Fully generic — your own states and events

```typescript
import { StateMachine } from "order-state-machine";

type RideState = "REQUESTED" | "ACCEPTED" | "IN_RIDE" | "COMPLETED" | "CANCELLED";
type RideEvent = "ACCEPT" | "START_RIDE" | "END_RIDE" | "CANCEL";

const ride = new StateMachine<RideState, RideEvent>({
  orderId: "ride_42",
  initialState: "REQUESTED",
  transitions: {
    REQUESTED: { ACCEPT: "ACCEPTED",    CANCEL: "CANCELLED" },
    ACCEPTED:  { START_RIDE: "IN_RIDE", CANCEL: "CANCELLED" },
    IN_RIDE:   { END_RIDE: "COMPLETED" },
  },
  terminalStates: ["COMPLETED", "CANCELLED"],
  activeStates:   ["ACCEPTED", "IN_RIDE"],
});

await ride.dispatch("ACCEPT");
await ride.dispatch("START_RIDE");
await ride.dispatch("END_RIDE");

console.log(ride.state); // "COMPLETED"
```

---

## Built-in Delivery Lifecycle

```
PENDING
  ├─ CONFIRM ──────────► CONFIRMED ──START_PREPARING──► PREPARING ──MARK_READY──► READY_FOR_PICKUP
  │                                                                                      │
  └─ CANCEL ──► CANCELLED ──REFUND──► REFUNDED                                       PICKUP
                                                                                         │
                                                                                    PICKED_UP
                                                                                         │
                                                                                   START_TRANSIT
                                                                                         │
                                                                                    IN_TRANSIT
                                                                                    ├─ DELIVER ──► DELIVERED ──REFUND──► REFUNDED
                                                                                    └─ FAIL    ──► FAILED    ──REFUND──► REFUNDED
```

Any state up to `READY_FOR_PICKUP` can also be cancelled.

---

## Hooks

Run side effects without coupling them to your business logic.

```typescript
const order = new DeliveryStateMachine({
  orderId: "ord_001",
  hooks: {
    onTransition: async ({ from, to, event, orderId, metadata }) => {
      await auditLog.record({ orderId, from, to, event });
    },
    onEnter: {
      DELIVERED: async ({ orderId }) => {
        await notifications.send(orderId, "Your order has been delivered!");
      },
      IN_TRANSIT: async ({ orderId, metadata }) => {
        await sms.send(orderId, `Rider on the way. ETA: ${metadata?.eta} mins`);
      },
    },
    onExit: {
      PREPARING: async () => {
        await kitchenDisplay.markOrderReady();
      },
    },
  },
});
```

Hooks fire in order: `onExit` (old state) → `onEnter` (new state) → `onTransition` (global).

---

## Metadata

Pass contextual data with any event. Stored in history and forwarded to all hooks.

```typescript
await order.dispatch("CONFIRM",      { confirmedBy: "ops-admin", channel: "web" });
await order.dispatch("PICKUP",       { riderId: "rider_42", vehicleType: "motorcycle" });
await order.dispatch("DELIVER",      { signedBy: "Jane D.", photoUrl: "https://cdn.example.com/pod.jpg" });
```

---

## Guard Checks

Use `can()` and `availableEvents()` to drive UI state without coupling your components to transition logic.

```typescript
order.can("DELIVER");       // false — not in transit yet
order.availableEvents();    // ["START_PREPARING", "CANCEL"]
order.isActive();           // true
order.isTerminal();         // false
```

---

## Transition History

Every transition is recorded automatically.

```typescript
console.log(order.history);
// [
//   { from: "PENDING",    to: "CONFIRMED",  event: "CONFIRM",          timestamp: Date, metadata: {...} },
//   { from: "CONFIRMED",  to: "PREPARING",  event: "START_PREPARING",  timestamp: Date },
//   ...
// ]
```

---

## Persistence

Save to a database or Redis and restore later without re-running hooks.

```typescript
// Save
const snapshot = order.toJSON();
await db.orders.update({ id: order.orderId }, { snapshot: JSON.stringify(snapshot) });

// Restore — DeliveryStateMachine
const saved = await db.orders.findById("ord_001");
const order = DeliveryStateMachine.fromDeliverySnapshot(JSON.parse(saved.snapshot));

// Restore — Generic StateMachine
const order = StateMachine.fromSnapshot<RideState, RideEvent>(snapshot, {
  transitions: RIDE_TRANSITIONS,
  terminalStates: ["COMPLETED", "CANCELLED"],
  activeStates: ["ACCEPTED", "IN_RIDE"],
});
```

---

## Custom Transitions

Override or extend the default delivery map. Useful for ghost kitchens, self-pickup, or 3PL integrations.

```typescript
// Ghost kitchen: customer collects from counter, no rider step
const order = new DeliveryStateMachine({
  orderId: "ghost_001",
  transitions: {
    PREPARING: { MARK_READY: "DELIVERED" },
  },
});
```

---

## Error Handling

```typescript
import { InvalidTransitionError } from "order-state-machine";

try {
  await order.dispatch("DELIVER"); // order is still PENDING
} catch (err) {
  if (err instanceof InvalidTransitionError) {
    console.error(err.message);
    // "Invalid transition: event "DELIVER" is not allowed from state "PENDING""
    console.log(err.from);  // "PENDING"
    console.log(err.event); // "DELIVER"
  }
}
```

---

## API Reference

### `new StateMachine<S, E>(config)`

| Option | Type | Required | Description |
|---|---|---|---|
| `orderId` | `string` | yes | Unique identifier for this entity |
| `initialState` | `S` | yes | Starting state |
| `transitions` | `TransitionMap<S, E>` | yes | Full transition map |
| `hooks` | `StateMachineHooks<S, E>` | no | Lifecycle hooks |
| `terminalStates` | `S[]` | no | States with no further transitions |
| `activeStates` | `S[]` | no | States representing active in-progress lifecycle |

### `new DeliveryStateMachine(config)`

Same as above but `initialState` defaults to `"PENDING"` and `transitions` defaults to the built-in delivery map. All options are optional.

### Instance Methods

| Method | Returns | Description |
|---|---|---|
| `dispatch(event, metadata?)` | `Promise<S>` | Trigger a transition |
| `can(event)` | `boolean` | Check if event is valid from current state |
| `availableEvents()` | `E[]` | All valid events from current state |
| `isTerminal()` | `boolean` | True if no further transitions possible |
| `isActive()` | `boolean` | True if in a configured active state |
| `toJSON()` | `object` | Serialize for persistence |
| `StateMachine.fromSnapshot(snap, config)` | `StateMachine<S,E>` | Restore from snapshot |
| `DeliveryStateMachine.fromDeliverySnapshot(snap, config?)` | `DeliveryStateMachine` | Restore delivery machine |

---

## Built-in Types

### States
`PENDING` `CONFIRMED` `PREPARING` `READY_FOR_PICKUP` `PICKED_UP` `IN_TRANSIT` `DELIVERED` `CANCELLED` `FAILED` `REFUNDED`

### Events
`CONFIRM` `START_PREPARING` `MARK_READY` `ASSIGN_RIDER` `PICKUP` `START_TRANSIT` `DELIVER` `CANCEL` `FAIL` `REFUND`

---

## Domain Examples

| Domain | Example States | Example Events |
|---|---|---|
| Food delivery | `PENDING, PREPARING, IN_TRANSIT, DELIVERED` | `CONFIRM, PICKUP, DELIVER` |
| Ride-hailing | `REQUESTED, ACCEPTED, IN_RIDE, COMPLETED` | `ACCEPT, START_RIDE, END_RIDE` |
| E-commerce | `CART, CHECKOUT, PAID, SHIPPED, RETURNED` | `PAY, SHIP, RETURN` |
| Laundry SaaS | `DROPPED_OFF, WASHING, DRYING, READY` | `WASH, DRY, COLLECT` |
| Ghost kitchen | Override `PREPARING > DELIVERED` directly | same delivery events |

---

## License 

MIT — [Badmus Sulaimon](https://github.com/haryobamy)