@unifiedcommerce/core 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/dist/auth/setup.d.ts.map +1 -1
  2. package/dist/auth/setup.js +8 -3
  3. package/dist/config/types.d.ts +3 -1
  4. package/dist/config/types.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/interfaces/mcp/server.d.ts +3 -5
  9. package/dist/interfaces/mcp/server.d.ts.map +1 -1
  10. package/dist/interfaces/mcp/server.js +25 -510
  11. package/dist/interfaces/mcp/tool-builder.d.ts +120 -0
  12. package/dist/interfaces/mcp/tool-builder.d.ts.map +1 -0
  13. package/dist/interfaces/mcp/tool-builder.js +224 -0
  14. package/dist/interfaces/mcp/tools/analytics.d.ts +42 -0
  15. package/dist/interfaces/mcp/tools/analytics.d.ts.map +1 -0
  16. package/dist/interfaces/mcp/tools/analytics.js +70 -0
  17. package/dist/interfaces/mcp/tools/cart.d.ts +14 -0
  18. package/dist/interfaces/mcp/tools/cart.d.ts.map +1 -0
  19. package/dist/interfaces/mcp/tools/cart.js +47 -0
  20. package/dist/interfaces/mcp/tools/catalog.d.ts +53 -0
  21. package/dist/interfaces/mcp/tools/catalog.d.ts.map +1 -0
  22. package/dist/interfaces/mcp/tools/catalog.js +284 -0
  23. package/dist/interfaces/mcp/tools/index.d.ts +3 -0
  24. package/dist/interfaces/mcp/tools/index.d.ts.map +1 -0
  25. package/dist/interfaces/mcp/tools/index.js +20 -0
  26. package/dist/interfaces/mcp/tools/inventory.d.ts +27 -0
  27. package/dist/interfaces/mcp/tools/inventory.d.ts.map +1 -0
  28. package/dist/interfaces/mcp/tools/inventory.js +143 -0
  29. package/dist/interfaces/mcp/tools/orders.d.ts +18 -0
  30. package/dist/interfaces/mcp/tools/orders.d.ts.map +1 -0
  31. package/dist/interfaces/mcp/tools/orders.js +82 -0
  32. package/dist/interfaces/mcp/tools/pricing.d.ts +29 -0
  33. package/dist/interfaces/mcp/tools/pricing.d.ts.map +1 -0
  34. package/dist/interfaces/mcp/tools/pricing.js +90 -0
  35. package/dist/interfaces/mcp/tools/promotions.d.ts +44 -0
  36. package/dist/interfaces/mcp/tools/promotions.d.ts.map +1 -0
  37. package/dist/interfaces/mcp/tools/promotions.js +109 -0
  38. package/dist/interfaces/mcp/tools/registry.d.ts +32 -0
  39. package/dist/interfaces/mcp/tools/registry.d.ts.map +1 -0
  40. package/dist/interfaces/mcp/tools/registry.js +55 -0
  41. package/dist/interfaces/mcp/tools/search.d.ts +14 -0
  42. package/dist/interfaces/mcp/tools/search.d.ts.map +1 -0
  43. package/dist/interfaces/mcp/tools/search.js +39 -0
  44. package/dist/interfaces/mcp/tools/webhooks.d.ts +15 -0
  45. package/dist/interfaces/mcp/tools/webhooks.d.ts.map +1 -0
  46. package/dist/interfaces/mcp/tools/webhooks.js +48 -0
  47. package/dist/interfaces/mcp/transport.d.ts +17 -2
  48. package/dist/interfaces/mcp/transport.d.ts.map +1 -1
  49. package/dist/interfaces/mcp/transport.js +91 -44
  50. package/dist/interfaces/rest/router.d.ts.map +1 -1
  51. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  52. package/dist/interfaces/rest/routes/checkout.js +1 -1
  53. package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
  54. package/dist/interfaces/rest/routes/promotions.js +3 -2
  55. package/dist/kernel/database/adapter.d.ts +8 -0
  56. package/dist/kernel/database/adapter.d.ts.map +1 -1
  57. package/dist/kernel/factory/repository-factory.d.ts.map +1 -1
  58. package/dist/kernel/factory/repository-factory.js +3 -1
  59. package/dist/kernel/local-api.d.ts.map +1 -1
  60. package/dist/kernel/local-api.js +2 -0
  61. package/dist/kernel/plugin/manifest.d.ts +3 -3
  62. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  63. package/dist/kernel/plugin/manifest.js +36 -7
  64. package/dist/runtime/kernel.d.ts +1 -2
  65. package/dist/runtime/kernel.d.ts.map +1 -1
  66. package/dist/runtime/kernel.js +16 -8
  67. package/dist/runtime/server.d.ts.map +1 -1
  68. package/dist/runtime/server.js +8 -3
  69. package/dist/test-utils/create-pglite-adapter.d.ts.map +1 -1
  70. package/dist/test-utils/create-pglite-adapter.js +7 -6
  71. package/dist/tsconfig.tsbuildinfo +1 -0
  72. package/package.json +3 -1
  73. package/src/adapters/console-email.ts +43 -0
  74. package/src/auth/access.ts +187 -0
  75. package/src/auth/auth-schema.ts +139 -0
  76. package/src/auth/middleware.ts +161 -0
  77. package/src/auth/org.ts +41 -0
  78. package/src/auth/permissions.ts +28 -0
  79. package/src/auth/setup.ts +171 -0
  80. package/src/auth/system-actor.ts +19 -0
  81. package/src/auth/types.ts +10 -0
  82. package/src/config/defaults.ts +82 -0
  83. package/src/config/define-config.ts +53 -0
  84. package/src/config/types.ts +301 -0
  85. package/src/generated/plugin-capabilities.d.ts +20 -0
  86. package/src/generated/plugin-manifest.ts +23 -0
  87. package/src/generated/plugin-repositories.d.ts +20 -0
  88. package/src/hooks/checkout-completion.ts +262 -0
  89. package/src/hooks/checkout.ts +677 -0
  90. package/src/hooks/order-emails.ts +62 -0
  91. package/src/index.ts +215 -0
  92. package/src/interfaces/mcp/agent-prompt.ts +174 -0
  93. package/src/interfaces/mcp/context-enrichment.ts +177 -0
  94. package/src/interfaces/mcp/server.ts +47 -0
  95. package/src/interfaces/mcp/tool-builder.ts +261 -0
  96. package/src/interfaces/mcp/tools/analytics.ts +76 -0
  97. package/src/interfaces/mcp/tools/cart.ts +57 -0
  98. package/src/interfaces/mcp/tools/catalog.ts +299 -0
  99. package/src/interfaces/mcp/tools/index.ts +22 -0
  100. package/src/interfaces/mcp/tools/inventory.ts +161 -0
  101. package/src/interfaces/mcp/tools/orders.ts +104 -0
  102. package/src/interfaces/mcp/tools/pricing.ts +94 -0
  103. package/src/interfaces/mcp/tools/promotions.ts +106 -0
  104. package/src/interfaces/mcp/tools/registry.ts +101 -0
  105. package/src/interfaces/mcp/tools/search.ts +42 -0
  106. package/src/interfaces/mcp/tools/webhooks.ts +48 -0
  107. package/src/interfaces/mcp/transport.ts +128 -0
  108. package/src/interfaces/rest/customer-portal.ts +299 -0
  109. package/src/interfaces/rest/index.ts +74 -0
  110. package/src/interfaces/rest/router.ts +333 -0
  111. package/src/interfaces/rest/routes/admin-jobs.ts +58 -0
  112. package/src/interfaces/rest/routes/audit.ts +50 -0
  113. package/src/interfaces/rest/routes/carts.ts +89 -0
  114. package/src/interfaces/rest/routes/catalog.ts +493 -0
  115. package/src/interfaces/rest/routes/checkout.ts +284 -0
  116. package/src/interfaces/rest/routes/inventory.ts +70 -0
  117. package/src/interfaces/rest/routes/media.ts +86 -0
  118. package/src/interfaces/rest/routes/orders.ts +78 -0
  119. package/src/interfaces/rest/routes/payments.ts +60 -0
  120. package/src/interfaces/rest/routes/pricing.ts +57 -0
  121. package/src/interfaces/rest/routes/promotions.ts +93 -0
  122. package/src/interfaces/rest/routes/search.ts +71 -0
  123. package/src/interfaces/rest/routes/webhooks.ts +46 -0
  124. package/src/interfaces/rest/schemas/admin-jobs.ts +40 -0
  125. package/src/interfaces/rest/schemas/audit.ts +46 -0
  126. package/src/interfaces/rest/schemas/carts.ts +125 -0
  127. package/src/interfaces/rest/schemas/catalog.ts +450 -0
  128. package/src/interfaces/rest/schemas/checkout.ts +66 -0
  129. package/src/interfaces/rest/schemas/customer-portal.ts +195 -0
  130. package/src/interfaces/rest/schemas/inventory.ts +138 -0
  131. package/src/interfaces/rest/schemas/media.ts +75 -0
  132. package/src/interfaces/rest/schemas/orders.ts +104 -0
  133. package/src/interfaces/rest/schemas/pricing.ts +80 -0
  134. package/src/interfaces/rest/schemas/promotions.ts +110 -0
  135. package/src/interfaces/rest/schemas/responses.ts +85 -0
  136. package/src/interfaces/rest/schemas/search.ts +58 -0
  137. package/src/interfaces/rest/schemas/shared.ts +62 -0
  138. package/src/interfaces/rest/schemas/webhooks.ts +68 -0
  139. package/src/interfaces/rest/utils.ts +104 -0
  140. package/src/interfaces/rest/webhook-router.ts +50 -0
  141. package/src/kernel/compensation/executor.ts +61 -0
  142. package/src/kernel/compensation/types.ts +26 -0
  143. package/src/kernel/database/adapter.ts +21 -0
  144. package/src/kernel/database/drizzle-db.ts +56 -0
  145. package/src/kernel/database/migrate.ts +76 -0
  146. package/src/kernel/database/plugin-types.ts +34 -0
  147. package/src/kernel/database/schema.ts +49 -0
  148. package/src/kernel/database/scoped-db.ts +68 -0
  149. package/src/kernel/database/tx-context.ts +46 -0
  150. package/src/kernel/error-mapper.ts +15 -0
  151. package/src/kernel/errors.ts +89 -0
  152. package/src/kernel/factory/repository-factory.ts +244 -0
  153. package/src/kernel/hooks/create-context.ts +43 -0
  154. package/src/kernel/hooks/executor.ts +88 -0
  155. package/src/kernel/hooks/registry.ts +74 -0
  156. package/src/kernel/hooks/types.ts +52 -0
  157. package/src/kernel/http-error.ts +44 -0
  158. package/src/kernel/jobs/adapter.ts +36 -0
  159. package/src/kernel/jobs/drizzle-adapter.ts +58 -0
  160. package/src/kernel/jobs/runner.ts +153 -0
  161. package/src/kernel/jobs/schema.ts +46 -0
  162. package/src/kernel/jobs/types.ts +30 -0
  163. package/src/kernel/local-api.ts +187 -0
  164. package/src/kernel/plugin/manifest.ts +271 -0
  165. package/src/kernel/query/executor.ts +184 -0
  166. package/src/kernel/query/registry.ts +46 -0
  167. package/src/kernel/result.ts +33 -0
  168. package/src/kernel/schema/extra-columns.ts +37 -0
  169. package/src/kernel/service-registry.ts +76 -0
  170. package/src/kernel/service-timing.ts +89 -0
  171. package/src/kernel/state-machine/machine.ts +101 -0
  172. package/src/modules/analytics/drizzle-adapter.ts +426 -0
  173. package/src/modules/analytics/hooks.ts +11 -0
  174. package/src/modules/analytics/models.ts +125 -0
  175. package/src/modules/analytics/repository/index.ts +6 -0
  176. package/src/modules/analytics/service.ts +245 -0
  177. package/src/modules/analytics/types.ts +180 -0
  178. package/src/modules/audit/hooks.ts +78 -0
  179. package/src/modules/audit/schema.ts +33 -0
  180. package/src/modules/audit/service.ts +151 -0
  181. package/src/modules/cart/access.ts +27 -0
  182. package/src/modules/cart/matcher.ts +26 -0
  183. package/src/modules/cart/repository/index.ts +234 -0
  184. package/src/modules/cart/schema.ts +42 -0
  185. package/src/modules/cart/schemas.ts +38 -0
  186. package/src/modules/cart/service.ts +541 -0
  187. package/src/modules/catalog/repository/index.ts +772 -0
  188. package/src/modules/catalog/schema.ts +203 -0
  189. package/src/modules/catalog/schemas.ts +104 -0
  190. package/src/modules/catalog/service.ts +1544 -0
  191. package/src/modules/customers/repository/index.ts +327 -0
  192. package/src/modules/customers/schema.ts +64 -0
  193. package/src/modules/customers/service.ts +171 -0
  194. package/src/modules/fulfillment/repository/index.ts +426 -0
  195. package/src/modules/fulfillment/schema.ts +101 -0
  196. package/src/modules/fulfillment/service.ts +555 -0
  197. package/src/modules/fulfillment/types.ts +59 -0
  198. package/src/modules/inventory/repository/index.ts +509 -0
  199. package/src/modules/inventory/schema.ts +94 -0
  200. package/src/modules/inventory/schemas.ts +38 -0
  201. package/src/modules/inventory/service.ts +490 -0
  202. package/src/modules/media/adapter.ts +17 -0
  203. package/src/modules/media/repository/index.ts +274 -0
  204. package/src/modules/media/schema.ts +41 -0
  205. package/src/modules/media/service.ts +151 -0
  206. package/src/modules/orders/repository/index.ts +287 -0
  207. package/src/modules/orders/schema.ts +66 -0
  208. package/src/modules/orders/service.ts +619 -0
  209. package/src/modules/orders/stale-order-cleanup.ts +76 -0
  210. package/src/modules/organization/service.ts +191 -0
  211. package/src/modules/payments/adapter.ts +47 -0
  212. package/src/modules/payments/repository/index.ts +6 -0
  213. package/src/modules/payments/service.ts +107 -0
  214. package/src/modules/pricing/repository/index.ts +291 -0
  215. package/src/modules/pricing/schema.ts +71 -0
  216. package/src/modules/pricing/schemas.ts +38 -0
  217. package/src/modules/pricing/service.ts +494 -0
  218. package/src/modules/promotions/repository/index.ts +325 -0
  219. package/src/modules/promotions/schema.ts +62 -0
  220. package/src/modules/promotions/schemas.ts +38 -0
  221. package/src/modules/promotions/service.ts +598 -0
  222. package/src/modules/search/adapter.ts +57 -0
  223. package/src/modules/search/hooks.ts +12 -0
  224. package/src/modules/search/repository/index.ts +6 -0
  225. package/src/modules/search/service.ts +315 -0
  226. package/src/modules/shipping/calculator.ts +188 -0
  227. package/src/modules/shipping/repository/index.ts +6 -0
  228. package/src/modules/shipping/service.ts +51 -0
  229. package/src/modules/tax/adapter.ts +60 -0
  230. package/src/modules/tax/repository/index.ts +6 -0
  231. package/src/modules/tax/service.ts +53 -0
  232. package/src/modules/webhooks/hook.ts +34 -0
  233. package/src/modules/webhooks/repository/index.ts +278 -0
  234. package/src/modules/webhooks/schema.ts +56 -0
  235. package/src/modules/webhooks/service.ts +117 -0
  236. package/src/modules/webhooks/signing.ts +6 -0
  237. package/src/modules/webhooks/ssrf-guard.ts +71 -0
  238. package/src/modules/webhooks/tasks.ts +52 -0
  239. package/src/modules/webhooks/worker.ts +134 -0
  240. package/src/runtime/commerce.ts +145 -0
  241. package/src/runtime/kernel.ts +426 -0
  242. package/src/runtime/logger.ts +36 -0
  243. package/src/runtime/server.ts +355 -0
  244. package/src/runtime/shutdown.ts +43 -0
  245. package/src/test-utils/create-pglite-adapter.ts +129 -0
  246. package/src/test-utils/create-plugin-test-app.ts +128 -0
  247. package/src/test-utils/create-repository-test-harness.ts +16 -0
  248. package/src/test-utils/create-test-config.ts +190 -0
  249. package/src/test-utils/create-test-kernel.ts +7 -0
  250. package/src/test-utils/create-test-plugin-context.ts +75 -0
  251. package/src/test-utils/rest-api-test-utils.ts +265 -0
  252. package/src/test-utils/test-actors.ts +62 -0
  253. package/src/test-utils/typed-hooks.ts +54 -0
  254. package/src/types/commerce-types.ts +34 -0
  255. package/src/utils/id.ts +3 -0
  256. package/src/utils/logger.ts +18 -0
  257. package/src/utils/pagination.ts +22 -0
@@ -0,0 +1,619 @@
1
+ import { resolveOrgId } from "../../auth/org.js";
2
+ import { assertOwnership, assertPermission } from "../../auth/permissions.js";
3
+ import type { Actor } from "../../auth/types.js";
4
+ import {
5
+ CommerceInvalidTransitionError,
6
+ CommerceNotFoundError,
7
+ CommerceValidationError,
8
+ toCommerceError,
9
+ } from "../../kernel/errors.js";
10
+ import { runAfterHooks, runBeforeHooks } from "../../kernel/hooks/executor.js";
11
+ import { createHookContext } from "../../kernel/hooks/create-context.js";
12
+ import type {
13
+ AfterHook,
14
+ BeforeHook,
15
+ HookContext,
16
+ } from "../../kernel/hooks/types.js";
17
+ import type { HookRegistry } from "../../kernel/hooks/registry.js";
18
+ import {
19
+ canTransition,
20
+ orderStateMachine,
21
+ type OrderState,
22
+ type StateDefinition,
23
+ } from "../../kernel/state-machine/machine.js";
24
+ import { Err, Ok, type Result } from "../../kernel/result.js";
25
+ import { createLogger } from "../../utils/logger.js";
26
+ import { paginate, type Pagination } from "../../utils/pagination.js";
27
+ import type { TxContext } from "../../kernel/database/tx-context.js";
28
+ import type { PluginDb } from "../../kernel/database/plugin-types.js";
29
+ import {
30
+ OrdersRepository,
31
+ type Order,
32
+ type OrderLineItem,
33
+ type OrderStatusHistory,
34
+ } from "./repository/index.js";
35
+
36
+ export interface CreateOrderInput {
37
+ customerId?: string;
38
+ currency: string;
39
+ subtotal: number;
40
+ taxTotal: number;
41
+ shippingTotal: number;
42
+ discountTotal?: number;
43
+ grandTotal: number;
44
+ paymentIntentId?: string | undefined;
45
+ paymentMethodId?: string | undefined;
46
+ metadata?: Record<string, unknown>;
47
+ lineItems: Array<{
48
+ entityId: string;
49
+ entityType: string;
50
+ variantId?: string;
51
+ sku?: string;
52
+ title: string;
53
+ quantity: number;
54
+ unitPrice: number;
55
+ totalPrice: number;
56
+ taxAmount?: number;
57
+ discountAmount?: number;
58
+ metadata?: Record<string, unknown>;
59
+ }>;
60
+ }
61
+
62
+ export interface ListOrdersParams {
63
+ page?: number;
64
+ limit?: number;
65
+ status?: string;
66
+ }
67
+
68
+ export interface ChangeStatusInput {
69
+ orderId: string;
70
+ newStatus: OrderState;
71
+ reason?: string;
72
+ }
73
+
74
+ export interface OrderServiceDeps {
75
+ repository: OrdersRepository;
76
+ hooks: HookRegistry;
77
+ services: Record<string, unknown>;
78
+ /** Custom state machine. If provided, overrides the default order transitions. */
79
+ stateMachine?: StateDefinition<string>;
80
+ /** Kernel database reference for hook contexts. Uses PluginDb to avoid circular Kernel import. */
81
+ kernel?: { database: { db: PluginDb } };
82
+ }
83
+
84
+ export type HydratedOrder = Order & { lineItems: OrderLineItem[] };
85
+ type OrderListResult = { items: HydratedOrder[]; pagination: Pagination };
86
+ type BeforeCreateOrderHook = BeforeHook<CreateOrderInput>;
87
+ type AfterCreateOrderHook = AfterHook<HydratedOrder>;
88
+ type StatusChangeHookInput = {
89
+ orderId: string;
90
+ fromStatus: OrderState;
91
+ newStatus: OrderState;
92
+ reason?: string;
93
+ };
94
+ type BeforeStatusChangeHook = BeforeHook<StatusChangeHookInput>;
95
+ type AfterStatusChangeHook = AfterHook<HydratedOrder>;
96
+
97
+ function context(
98
+ actor: Actor | null,
99
+ services: Record<string, unknown>,
100
+ tx: unknown = null,
101
+ kernel: { database: { db: PluginDb } } | null = null,
102
+ ): HookContext {
103
+ return createHookContext({
104
+ actor,
105
+ tx,
106
+ logger: createLogger("orders"),
107
+ services,
108
+ context: { moduleName: "orders" },
109
+ ...(kernel != null ? { kernel } : {}),
110
+ });
111
+ }
112
+
113
+ export class OrderService {
114
+ private readonly repo: OrdersRepository;
115
+ private readonly machine: StateDefinition<string>;
116
+
117
+ constructor(private deps: OrderServiceDeps) {
118
+ this.repo = deps.repository;
119
+ this.machine = deps.stateMachine ?? orderStateMachine;
120
+ }
121
+
122
+ private async hydrateOrder(
123
+ order: Order,
124
+ ctx?: TxContext,
125
+ ): Promise<HydratedOrder> {
126
+ const lineItems = await this.repo.findLineItemsByOrderId(order.id, ctx);
127
+ return { ...order, lineItems };
128
+ }
129
+
130
+ async create(
131
+ input: CreateOrderInput,
132
+ actor: Actor | null,
133
+ ctx?: TxContext,
134
+ ): Promise<Result<HydratedOrder>> {
135
+ try {
136
+ assertPermission(actor, "orders:create");
137
+ } catch (error) {
138
+ return Err(toCommerceError(error));
139
+ }
140
+
141
+ if (input.lineItems.length === 0) {
142
+ return Err(
143
+ new CommerceValidationError("Order requires at least one line item."),
144
+ );
145
+ }
146
+
147
+ const beforeHooks = this.deps.hooks.resolve(
148
+ "orders.beforeCreate",
149
+ ) as BeforeCreateOrderHook[];
150
+ const afterHooks = this.deps.hooks.resolve(
151
+ "orders.afterCreate",
152
+ ) as AfterCreateOrderHook[];
153
+ const hookCtx = context(actor, this.deps.services, ctx?.tx, this.deps.kernel);
154
+
155
+ const processed = await runBeforeHooks(
156
+ beforeHooks,
157
+ input,
158
+ "create",
159
+ hookCtx,
160
+ );
161
+
162
+ const orderNumber = await this.repo.getNextOrderNumber(ctx);
163
+ const orgId = resolveOrgId(actor);
164
+
165
+ const order = await this.repo.create(
166
+ {
167
+ organizationId: orgId,
168
+ orderNumber,
169
+ status: "pending",
170
+ currency: processed.currency,
171
+ subtotal: processed.subtotal,
172
+ taxTotal: processed.taxTotal,
173
+ shippingTotal: processed.shippingTotal,
174
+ discountTotal: processed.discountTotal ?? 0,
175
+ grandTotal: processed.grandTotal,
176
+ ...(processed.paymentIntentId != null ? { paymentIntentId: processed.paymentIntentId } : {}),
177
+ ...(processed.paymentMethodId != null ? { paymentMethodId: processed.paymentMethodId } : {}),
178
+ metadata: processed.metadata ?? {},
179
+ placedAt: new Date(),
180
+ ...(processed.customerId !== undefined
181
+ ? { customerId: processed.customerId }
182
+ : {}),
183
+ },
184
+ ctx,
185
+ );
186
+
187
+ const lineItemsData = processed.lineItems.map((item) => ({
188
+ orderId: order.id,
189
+ entityId: item.entityId,
190
+ entityType: item.entityType,
191
+ title: item.title,
192
+ quantity: item.quantity,
193
+ unitPrice: item.unitPrice,
194
+ totalPrice: item.totalPrice,
195
+ taxAmount: item.taxAmount ?? 0,
196
+ discountAmount: item.discountAmount ?? 0,
197
+ fulfillmentStatus: "unfulfilled" as const,
198
+ metadata: item.metadata ?? {},
199
+ ...(item.variantId !== undefined ? { variantId: item.variantId } : {}),
200
+ ...(item.sku !== undefined ? { sku: item.sku } : {}),
201
+ }));
202
+
203
+ await this.repo.createLineItems(lineItemsData, ctx);
204
+
205
+ await this.repo.createStatusHistory(
206
+ {
207
+ orderId: order.id,
208
+ fromStatus: "pending",
209
+ toStatus: "pending",
210
+ reason: "order_created",
211
+ changedBy: actor?.userId ?? "system",
212
+ },
213
+ ctx,
214
+ );
215
+
216
+ const hydrated = await this.hydrateOrder(order, ctx);
217
+ const report = await runAfterHooks(
218
+ afterHooks,
219
+ null,
220
+ hydrated,
221
+ "create",
222
+ hookCtx,
223
+ );
224
+
225
+ return Ok(
226
+ hydrated,
227
+ report.hasErrors ? { hookErrors: report.errors } : undefined,
228
+ );
229
+ }
230
+
231
+ async getById(
232
+ id: string,
233
+ actor: Actor | null,
234
+ ctx?: TxContext,
235
+ ): Promise<Result<HydratedOrder>> {
236
+ const orgId = resolveOrgId(actor);
237
+ const order = await this.repo.findById(orgId, id, ctx);
238
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
239
+
240
+ try {
241
+ if (
242
+ actor?.permissions.includes("orders:read") ||
243
+ actor?.permissions.includes("*:*")
244
+ ) {
245
+ // no-op
246
+ } else if (actor?.permissions.includes("orders:read:own")) {
247
+ assertOwnership(actor, order.customerId ?? null);
248
+ } else {
249
+ assertPermission(actor, "orders:read");
250
+ }
251
+ } catch (error) {
252
+ return Err(toCommerceError(error));
253
+ }
254
+
255
+ const hydrated = await this.hydrateOrder(order, ctx);
256
+
257
+ // Run afterGet hooks — allows plugins to enrich the order (e.g., vendor fulfillment)
258
+ const afterGetHooks = this.deps.hooks.resolve(
259
+ "orders.afterGet",
260
+ ) as AfterHook<HydratedOrder>[];
261
+ if (afterGetHooks.length > 0) {
262
+ const hookCtx = context(actor, this.deps.services, ctx?.tx, this.deps.kernel);
263
+ await runAfterHooks(afterGetHooks, null, hydrated, "read", hookCtx);
264
+ }
265
+
266
+ return Ok(hydrated);
267
+ }
268
+
269
+ async getByNumber(
270
+ orderNumber: string,
271
+ actor: Actor | null,
272
+ ctx?: TxContext,
273
+ ): Promise<Result<HydratedOrder>> {
274
+ const orgId = resolveOrgId(actor);
275
+ const order = await this.repo.findByOrderNumber(orgId, orderNumber, ctx);
276
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
277
+ return this.getById(order.id, actor, ctx);
278
+ }
279
+
280
+ async list(
281
+ params: ListOrdersParams,
282
+ actor: Actor | null,
283
+ ctx?: TxContext,
284
+ ): Promise<Result<OrderListResult>> {
285
+ try {
286
+ assertPermission(actor, "orders:read");
287
+ } catch (error) {
288
+ return Err(toCommerceError(error));
289
+ }
290
+
291
+ const orgId = resolveOrgId(actor);
292
+ let items: Order[];
293
+ if (params.status) {
294
+ items = await this.repo.findByStatus(orgId, params.status, ctx);
295
+ } else {
296
+ items = await this.repo.findAll(orgId, undefined, ctx);
297
+ }
298
+
299
+ // Sort by placedAt descending (in-memory, as findAll already sorts)
300
+ items.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime());
301
+
302
+ const paged = paginate(items, params.page ?? 1, params.limit ?? 20);
303
+ const hydratedItems = await Promise.all(
304
+ paged.items.map((order) => this.hydrateOrder(order, ctx)),
305
+ );
306
+
307
+ return Ok({
308
+ items: hydratedItems,
309
+ pagination: paged.pagination,
310
+ });
311
+ }
312
+
313
+ async listByCustomer(
314
+ customerId: string,
315
+ params: ListOrdersParams,
316
+ actor?: Actor | null,
317
+ ctx?: TxContext,
318
+ ): Promise<Result<OrderListResult>> {
319
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
320
+ let items = await this.repo.findByCustomerId(orgId, customerId, ctx);
321
+
322
+ if (params.status) {
323
+ items = items.filter((order) => order.status === params.status);
324
+ }
325
+
326
+ items.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime());
327
+ const paged = paginate(items, params.page ?? 1, params.limit ?? 20);
328
+ const hydratedItems = await Promise.all(
329
+ paged.items.map((order) => this.hydrateOrder(order, ctx)),
330
+ );
331
+
332
+ return Ok({
333
+ items: hydratedItems,
334
+ pagination: paged.pagination,
335
+ });
336
+ }
337
+
338
+ async changeStatus(
339
+ input: ChangeStatusInput,
340
+ actor: Actor | null,
341
+ ctx?: TxContext,
342
+ ): Promise<Result<HydratedOrder>> {
343
+ const orgId = resolveOrgId(actor);
344
+ const order = await this.repo.findById(orgId, input.orderId, ctx);
345
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
346
+
347
+ try {
348
+ assertPermission(actor, "orders:update");
349
+ } catch (error) {
350
+ return Err(toCommerceError(error));
351
+ }
352
+
353
+ if (
354
+ !canTransition(
355
+ this.machine,
356
+ order.status as OrderState,
357
+ input.newStatus,
358
+ )
359
+ ) {
360
+ return Err(
361
+ new CommerceInvalidTransitionError(
362
+ `Cannot transition from ${order.status} to ${input.newStatus}.`,
363
+ ),
364
+ );
365
+ }
366
+
367
+ const beforeHooks = this.deps.hooks.resolve(
368
+ "orders.beforeStatusChange",
369
+ ) as BeforeStatusChangeHook[];
370
+ const afterHooks = this.deps.hooks.resolve(
371
+ "orders.afterStatusChange",
372
+ ) as AfterStatusChangeHook[];
373
+
374
+ const hookCtx = context(actor, this.deps.services, ctx?.tx, this.deps.kernel);
375
+ const statusHookInput: StatusChangeHookInput = {
376
+ orderId: order.id,
377
+ fromStatus: order.status as OrderState,
378
+ newStatus: input.newStatus,
379
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
380
+ };
381
+
382
+ await runBeforeHooks(beforeHooks, statusHookInput, "statusChange", hookCtx);
383
+
384
+ const previous = order.status;
385
+ const lineItems = await this.repo.findLineItemsByOrderId(order.id, ctx);
386
+
387
+ // Handle cancellation and refund side effects
388
+ if (input.newStatus === "cancelled" || input.newStatus === "refunded") {
389
+ // 1. Release inventory reservations
390
+ const inventory = this.deps.services.inventory as
391
+ | {
392
+ release(input: {
393
+ entityId: string;
394
+ variantId?: string;
395
+ quantity: number;
396
+ orderId: string;
397
+ performedBy?: string;
398
+ }): Promise<unknown>;
399
+ }
400
+ | undefined;
401
+
402
+ if (inventory?.release) {
403
+ for (const lineItem of lineItems) {
404
+ // Only release inventory for unfulfilled items.
405
+ // Fulfilled items had their reservation released during the
406
+ // fulfilled transition — releasing again would double-release.
407
+ if (lineItem.fulfillmentStatus === "unfulfilled") {
408
+ await inventory.release({
409
+ entityId: lineItem.entityId,
410
+ quantity: lineItem.quantity,
411
+ orderId: order.id,
412
+ performedBy: actor?.userId ?? "system",
413
+ ...(lineItem.variantId != null
414
+ ? { variantId: lineItem.variantId }
415
+ : {}),
416
+ });
417
+ }
418
+ }
419
+ }
420
+
421
+ // 2. Refund payment (if captured)
422
+ const paymentIntentId =
423
+ (order as Record<string, unknown>).paymentIntentId as string | undefined
424
+ ?? (order.metadata as Record<string, unknown> | null)?.paymentIntentId as string | undefined;
425
+
426
+ if (paymentIntentId) {
427
+ const payments = this.deps.services.payments as
428
+ | {
429
+ refund(
430
+ paymentId: string,
431
+ amount: number,
432
+ reason?: string,
433
+ ): Promise<unknown>;
434
+ }
435
+ | undefined;
436
+
437
+ if (payments?.refund) {
438
+ await payments.refund(
439
+ paymentIntentId,
440
+ order.grandTotal,
441
+ input.reason ?? `order_${input.newStatus}`,
442
+ );
443
+ }
444
+ }
445
+
446
+ // 3. Void tax transaction
447
+ const tax = this.deps.services.tax as
448
+ | {
449
+ voidTransaction(input: { transactionId: string }): Promise<unknown>;
450
+ }
451
+ | undefined;
452
+ if (tax?.voidTransaction) {
453
+ await tax.voidTransaction({ transactionId: order.id });
454
+ }
455
+ }
456
+
457
+ // Handle fulfillment: deduct on_hand and release reservations.
458
+ // Items have been shipped — on_hand decreases (stock left the warehouse)
459
+ // and reservations are cleared (no longer needed).
460
+ //
461
+ // Net effect per line item:
462
+ // on_hand -= quantity, reserved -= quantity, available unchanged
463
+ // Before: on_hand=100, reserved=5, available=95
464
+ // After: on_hand=95, reserved=0, available=95
465
+ if (input.newStatus === "fulfilled" || input.newStatus === "partially_fulfilled") {
466
+ const inventory = this.deps.services.inventory as
467
+ | {
468
+ deductForFulfillment(input: {
469
+ entityId: string;
470
+ variantId?: string;
471
+ quantity: number;
472
+ orderId: string;
473
+ }): Promise<unknown>;
474
+ release(input: {
475
+ entityId: string;
476
+ variantId?: string;
477
+ quantity: number;
478
+ orderId: string;
479
+ performedBy?: string;
480
+ }): Promise<unknown>;
481
+ }
482
+ | undefined;
483
+
484
+ if (inventory) {
485
+ for (const lineItem of lineItems) {
486
+ if (lineItem.fulfillmentStatus === "unfulfilled") {
487
+ // Deduct on_hand (stock physically left warehouse)
488
+ await inventory.deductForFulfillment({
489
+ entityId: lineItem.entityId,
490
+ quantity: lineItem.quantity,
491
+ orderId: order.id,
492
+ ...(lineItem.variantId != null
493
+ ? { variantId: lineItem.variantId }
494
+ : {}),
495
+ });
496
+
497
+ // Release reservation (no longer needed)
498
+ await inventory.release({
499
+ entityId: lineItem.entityId,
500
+ quantity: lineItem.quantity,
501
+ orderId: order.id,
502
+ performedBy: actor?.userId ?? "system",
503
+ ...(lineItem.variantId != null
504
+ ? { variantId: lineItem.variantId }
505
+ : {}),
506
+ });
507
+
508
+ // Mark line item as fulfilled
509
+ await this.repo.updateLineItem(
510
+ lineItem.id,
511
+ { fulfillmentStatus: "fulfilled" },
512
+ ctx,
513
+ );
514
+ }
515
+ }
516
+ }
517
+ }
518
+
519
+ // Update order status with atomic guard on current status
520
+ const updated = await this.repo.updateStatus(
521
+ order.id,
522
+ previous,
523
+ input.newStatus,
524
+ ctx,
525
+ );
526
+ if (!updated) {
527
+ return Err(new CommerceValidationError(
528
+ `Order status changed concurrently. Refresh and retry.`,
529
+ ));
530
+ }
531
+
532
+ await this.repo.createStatusHistory(
533
+ {
534
+ orderId: order.id,
535
+ fromStatus: previous,
536
+ toStatus: input.newStatus,
537
+ changedBy: actor?.userId ?? "system",
538
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
539
+ },
540
+ ctx,
541
+ );
542
+
543
+ // Audit logging is now automatic via audit hooks (RFC-005)
544
+ // registered in kernel boot — no manual audit.record() needed.
545
+
546
+ const hydrated = await this.hydrateOrder(updated, ctx);
547
+ const report = await runAfterHooks(
548
+ afterHooks,
549
+ null,
550
+ hydrated,
551
+ "statusChange",
552
+ hookCtx,
553
+ );
554
+
555
+ return Ok(
556
+ hydrated,
557
+ report.hasErrors ? { hookErrors: report.errors } : undefined,
558
+ );
559
+ }
560
+
561
+ async cancel(
562
+ orderId: string,
563
+ actor: Actor | null,
564
+ reason = "cancelled_by_user",
565
+ ctx?: TxContext,
566
+ ): Promise<Result<HydratedOrder>> {
567
+ return this.changeStatus(
568
+ { orderId, newStatus: "cancelled", reason },
569
+ actor,
570
+ ctx,
571
+ );
572
+ }
573
+
574
+ async refund(
575
+ orderId: string,
576
+ actor: Actor | null,
577
+ reason = "refunded",
578
+ ctx?: TxContext,
579
+ ): Promise<Result<HydratedOrder>> {
580
+ return this.changeStatus(
581
+ { orderId, newStatus: "refunded", reason },
582
+ actor,
583
+ ctx,
584
+ );
585
+ }
586
+
587
+ async getStatusHistory(
588
+ orderId: string,
589
+ actor?: Actor | null,
590
+ ctx?: TxContext,
591
+ ): Promise<Result<OrderStatusHistory[]>> {
592
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
593
+ const order = await this.repo.findById(orgId, orderId, ctx);
594
+ if (!order) {
595
+ return Err(new CommerceNotFoundError("Order not found."));
596
+ }
597
+
598
+ const items = await this.repo.findStatusHistoryByOrderId(orderId, ctx);
599
+ // Sort by changedAt ascending (oldest first)
600
+ items.sort((a, b) => a.changedAt.getTime() - b.changedAt.getTime());
601
+
602
+ return Ok(items);
603
+ }
604
+
605
+ async updateOrder(
606
+ orderId: string,
607
+ data: { placedAt?: Date; metadata?: Record<string, unknown> },
608
+ actor?: Actor | null,
609
+ ctx?: TxContext,
610
+ ): Promise<Result<Order>> {
611
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
612
+ const order = await this.repo.findById(orgId, orderId, ctx);
613
+ if (!order) {
614
+ return Err(new CommerceNotFoundError("Order not found."));
615
+ }
616
+ const updated = await this.repo.update(orderId, data, ctx);
617
+ return Ok(updated!);
618
+ }
619
+ }
@@ -0,0 +1,76 @@
1
+ import { eq, and, lt, sql } from "drizzle-orm";
2
+ import { orders } from "./schema.js";
3
+ import type { TaskDefinition, TaskContext } from "../../kernel/jobs/types.js";
4
+
5
+ /**
6
+ * Stale Order Cleanup Task
7
+ *
8
+ * Cancels orders stuck in "pending" status for longer than the configured
9
+ * threshold (default: 48 hours). This releases reserved inventory and
10
+ * refunds captured payments, preventing phantom stock loss from abandoned
11
+ * orders.
12
+ *
13
+ * Register via config.jobs.tasks:
14
+ * ```ts
15
+ * import { staleOrderCleanupTask } from "@unifiedcommerce/core";
16
+ * defineConfig({
17
+ * jobs: {
18
+ * tasks: [staleOrderCleanupTask],
19
+ * autorun: { enabled: true, intervalMs: 3600_000 }, // hourly
20
+ * },
21
+ * });
22
+ * ```
23
+ */
24
+ export const staleOrderCleanupTask: TaskDefinition<
25
+ { thresholdHours?: number },
26
+ { cancelledCount: number; orderIds: string[] }
27
+ > = {
28
+ slug: "orders/stale-cleanup",
29
+
30
+ async handler({ input, ctx }) {
31
+ const thresholdHours = input.thresholdHours ?? 48;
32
+ const cutoff = new Date(Date.now() - thresholdHours * 60 * 60 * 1000);
33
+
34
+ // Find stale pending orders
35
+ const staleOrders = await ctx.db
36
+ .select()
37
+ .from(orders)
38
+ .where(
39
+ and(
40
+ eq(orders.status, "pending"),
41
+ lt(orders.placedAt, cutoff),
42
+ ),
43
+ );
44
+
45
+ const cancelledIds: string[] = [];
46
+
47
+ // Cancel each stale order via the service (triggers inventory release + payment refund)
48
+ const orderService = ctx.services.orders as {
49
+ cancel(orderId: string, actor: null, reason: string): Promise<unknown>;
50
+ };
51
+
52
+ for (const order of staleOrders) {
53
+ try {
54
+ await orderService.cancel(
55
+ order.id,
56
+ null,
57
+ `Auto-cancelled: pending for >${thresholdHours}h`,
58
+ );
59
+ cancelledIds.push(order.id);
60
+ ctx.logger.info(`Stale order ${order.orderNumber} auto-cancelled`, {
61
+ orderId: order.id,
62
+ placedAt: order.placedAt,
63
+ });
64
+ } catch (error) {
65
+ ctx.logger.error(`Failed to cancel stale order ${order.orderNumber}`, { error });
66
+ }
67
+ }
68
+
69
+ return {
70
+ output: {
71
+ cancelledCount: cancelledIds.length,
72
+ orderIds: cancelledIds,
73
+ },
74
+ };
75
+ },
76
+ };