@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,677 @@
1
+ import { CommerceValidationError } from "../kernel/errors.js";
2
+ import type { AfterHook, BeforeHook } from "../kernel/hooks/types.js";
3
+ import type { ShippingAddress } from "../modules/shipping/calculator.js";
4
+ import type { AppliedPromotion } from "../modules/promotions/service.js";
5
+ import { runCompensationChain } from "../kernel/compensation/executor.js";
6
+ import type { CompensationContext } from "../kernel/compensation/types.js";
7
+ import type { TxContext } from "../kernel/database/tx-context.js";
8
+ import {
9
+ reserveInventoryStep,
10
+ capturePaymentStep,
11
+ initiateFulfillmentStep,
12
+ sendConfirmationStep,
13
+ } from "./checkout-completion.js";
14
+
15
+ export interface OrderResult {
16
+ id: string;
17
+ status?: string | undefined;
18
+ customerId?: string | null | undefined;
19
+ currency: string;
20
+ subtotal?: number | undefined;
21
+ discountTotal?: number | undefined;
22
+ taxTotal?: number | undefined;
23
+ shippingTotal?: number | undefined;
24
+ grandTotal?: number | undefined;
25
+ metadata?: Record<string, unknown> | null | undefined;
26
+ lineItems?: Array<{
27
+ entityId: string;
28
+ entityType?: string | undefined;
29
+ title?: string | undefined;
30
+ variantId?: string | null | undefined;
31
+ quantity: number;
32
+ unitPrice?: number | undefined;
33
+ totalPrice?: number | undefined;
34
+ }> | undefined;
35
+ }
36
+
37
+ export interface CheckoutLineItem {
38
+ id?: string;
39
+ entityId: string;
40
+ entityType?: string;
41
+ title?: string;
42
+ variantId?: string;
43
+ quantity: number;
44
+ resolvedUnitPrice?: number;
45
+ resolvedTotal?: number;
46
+ discountAmount?: number;
47
+ taxAmount?: number;
48
+ priceBreakdown?: Array<{
49
+ label: string;
50
+ amountBefore: number;
51
+ delta: number;
52
+ amountAfter: number;
53
+ }>;
54
+ }
55
+
56
+ export interface CheckoutData {
57
+ checkoutId: string;
58
+ cartId: string;
59
+ customerId?: string;
60
+ customerGroupIds?: string[];
61
+ currency: string;
62
+ paymentMethodId: string;
63
+ lineItems: CheckoutLineItem[];
64
+ subtotal: number;
65
+ discountTotal: number;
66
+ taxTotal: number;
67
+ shippingTotal: number;
68
+ total: number;
69
+ promotionCodes?: string[];
70
+ paymentIntentId?: string;
71
+ paymentClientSecret?: string | undefined;
72
+ shippingAddress?: ShippingAddress;
73
+ appliedPromotions?: AppliedPromotion[];
74
+ freeShipping?: boolean;
75
+ taxTransactionId?: string;
76
+ metadata?: Record<string, unknown>;
77
+ }
78
+
79
+ function recalculateTotals(data: CheckoutData): void {
80
+ data.total = Math.max(
81
+ 0,
82
+ data.subtotal - data.discountTotal + data.taxTotal + data.shippingTotal,
83
+ );
84
+ }
85
+
86
+ export const validateCartNotEmpty: BeforeHook<CheckoutData> = async ({
87
+ data,
88
+ context,
89
+ }) => {
90
+ const cartService = context.services.cart as {
91
+ getById(id: string, actor?: unknown): Promise<
92
+ | {
93
+ ok: true;
94
+ value: {
95
+ organizationId?: string;
96
+ status?: string;
97
+ expiresAt?: Date | string;
98
+ lineItems: Array<{
99
+ id: string;
100
+ entityId: string;
101
+ variantId?: string | null;
102
+ quantity: number;
103
+ }>;
104
+ };
105
+ }
106
+ | { ok: false }
107
+ >;
108
+ claimForCheckout(cartId: string, ctx?: unknown): Promise<
109
+ | { ok: true; value: { id: string } }
110
+ | { ok: false; error: { message: string } }
111
+ >;
112
+ };
113
+ const catalogService = context.services.catalog as {
114
+ getById(
115
+ id: string,
116
+ options?: { includeAttributes?: boolean },
117
+ ): Promise<
118
+ | {
119
+ ok: true;
120
+ value: {
121
+ type: string;
122
+ attributes?: Array<{ title: string; locale: string }>;
123
+ };
124
+ }
125
+ | { ok: false }
126
+ >;
127
+ };
128
+
129
+ // M1 fix: Atomically claim the cart for checkout (active → checking_out).
130
+ // If a concurrent request already claimed it, this returns Err and we fail fast.
131
+ const claimed = await cartService.claimForCheckout(data.cartId, context.tx);
132
+ if (!claimed.ok) {
133
+ throw new CommerceValidationError(
134
+ "Cart is not available for checkout. It may have already been checked out by a concurrent request.",
135
+ );
136
+ }
137
+
138
+ const cart = await cartService.getById(data.cartId, context.actor);
139
+ if (!cart.ok || cart.value.lineItems.length === 0) {
140
+ throw new CommerceValidationError("Cannot checkout an empty cart.");
141
+ }
142
+
143
+ // Cross-org guard: prevent org B from checking out org A's cart
144
+ const actorOrgId = context.actor?.organizationId ?? "org_default";
145
+ if (cart.value.organizationId && cart.value.organizationId !== actorOrgId) {
146
+ throw new CommerceValidationError("Cart does not belong to this organization.");
147
+ }
148
+
149
+ // Reject expired carts
150
+ if (cart.value.expiresAt) {
151
+ const expiry = cart.value.expiresAt instanceof Date ? cart.value.expiresAt : new Date(cart.value.expiresAt);
152
+ if (expiry.getTime() < Date.now()) {
153
+ throw new CommerceValidationError("Cart has expired. Please create a new cart.");
154
+ }
155
+ }
156
+
157
+ // Enrich line items with entity title and type from catalog (in parallel)
158
+ data.lineItems = await Promise.all(
159
+ cart.value.lineItems.map(async (item) => {
160
+ const entity = await catalogService.getById(item.entityId, {
161
+ includeAttributes: true,
162
+ });
163
+ const title = entity.ok
164
+ ? (entity.value.attributes?.[0]?.title ?? item.entityId)
165
+ : item.entityId;
166
+ const entityType = entity.ok ? entity.value.type : "product";
167
+ return {
168
+ id: item.id,
169
+ entityId: item.entityId,
170
+ entityType,
171
+ title,
172
+ quantity: item.quantity,
173
+ // Use != null to exclude both null (DB value) and undefined
174
+ ...(item.variantId != null ? { variantId: item.variantId } : {}),
175
+ };
176
+ }),
177
+ );
178
+
179
+ return data;
180
+ };
181
+
182
+ export const resolveCurrentPrices: BeforeHook<CheckoutData> = async ({
183
+ data,
184
+ context,
185
+ }) => {
186
+ const pricing = context.services.pricing as {
187
+ resolve(params: {
188
+ entityId: string;
189
+ variantId?: string;
190
+ currency: string;
191
+ quantity: number;
192
+ customerId?: string;
193
+ customerGroupIds?: string[];
194
+ }): Promise<
195
+ | {
196
+ ok: true;
197
+ value: {
198
+ finalAmount: number;
199
+ breakdown: Array<{
200
+ label: string;
201
+ amountBefore: number;
202
+ delta: number;
203
+ amountAfter: number;
204
+ }>;
205
+ };
206
+ }
207
+ | { ok: false }
208
+ >;
209
+ };
210
+
211
+ for (const item of data.lineItems) {
212
+ const price = await pricing.resolve({
213
+ entityId: item.entityId,
214
+ currency: data.currency,
215
+ quantity: item.quantity,
216
+ ...(item.variantId !== undefined ? { variantId: item.variantId } : {}),
217
+ ...(data.customerId !== undefined ? { customerId: data.customerId } : {}),
218
+ ...(data.customerGroupIds !== undefined
219
+ ? { customerGroupIds: data.customerGroupIds }
220
+ : {}),
221
+ });
222
+
223
+ if (!price.ok) {
224
+ throw new CommerceValidationError(
225
+ `Cannot resolve price for ${item.entityId}.`,
226
+ );
227
+ }
228
+
229
+ item.resolvedUnitPrice = price.value.finalAmount;
230
+ item.resolvedTotal = price.value.finalAmount * item.quantity;
231
+ item.priceBreakdown = price.value.breakdown;
232
+ item.discountAmount = 0;
233
+ item.taxAmount = 0;
234
+ }
235
+
236
+ data.subtotal = data.lineItems.reduce(
237
+ (sum, item) => sum + (item.resolvedTotal ?? 0),
238
+ 0,
239
+ );
240
+ recalculateTotals(data);
241
+ return data;
242
+ };
243
+
244
+ export const checkInventoryAvailability: BeforeHook<CheckoutData> = async ({
245
+ data,
246
+ context,
247
+ }) => {
248
+ const inventory = context.services.inventory as {
249
+ getAvailable(
250
+ entityId: string,
251
+ variantId?: string,
252
+ ctx?: unknown,
253
+ ): Promise<{ ok: boolean; value?: number }>;
254
+ };
255
+
256
+ for (const item of data.lineItems) {
257
+ const available = await inventory.getAvailable(
258
+ item.entityId,
259
+ item.variantId,
260
+ context.tx,
261
+ );
262
+ if (!available.ok || (available.value ?? 0) < item.quantity) {
263
+ throw new CommerceValidationError(
264
+ `Insufficient stock for ${item.title ?? item.entityId}. Available: ${
265
+ available.ok ? (available.value ?? 0) : 0
266
+ }, requested: ${item.quantity}.`,
267
+ );
268
+ }
269
+ }
270
+
271
+ return data;
272
+ };
273
+
274
+ export const applyPromotionCodes: BeforeHook<CheckoutData> = async ({
275
+ data,
276
+ context,
277
+ }) => {
278
+ const promotions = context.services.promotions as {
279
+ applyPromotions(input: {
280
+ orgId?: string;
281
+ cartId?: string;
282
+ customerId?: string;
283
+ customerGroupIds?: string[];
284
+ currency: string;
285
+ subtotal: number;
286
+ lineItems: Array<{
287
+ entityId: string;
288
+ entityType: string;
289
+ quantity: number;
290
+ unitPrice: number;
291
+ totalPrice: number;
292
+ }>;
293
+ promotionCodes?: string[];
294
+ }): Promise<
295
+ | {
296
+ ok: true;
297
+ value: {
298
+ totalDiscount: number;
299
+ freeShipping: boolean;
300
+ applied: AppliedPromotion[];
301
+ rejectedCodes: Array<{ code: string; reason: string }>;
302
+ };
303
+ }
304
+ | { ok: false; error: Error }
305
+ >;
306
+ };
307
+
308
+ const result = await promotions.applyPromotions({
309
+ orgId: context.actor?.organizationId ?? "org_default",
310
+ cartId: data.cartId,
311
+ currency: data.currency,
312
+ subtotal: data.subtotal,
313
+ lineItems: data.lineItems.map((lineItem) => ({
314
+ entityId: lineItem.entityId,
315
+ entityType: lineItem.entityType ?? "product",
316
+ quantity: lineItem.quantity,
317
+ unitPrice: lineItem.resolvedUnitPrice ?? 0,
318
+ totalPrice: lineItem.resolvedTotal ?? 0,
319
+ })),
320
+ ...(data.customerId !== undefined ? { customerId: data.customerId } : {}),
321
+ ...(data.customerGroupIds !== undefined
322
+ ? { customerGroupIds: data.customerGroupIds }
323
+ : {}),
324
+ ...(data.promotionCodes !== undefined
325
+ ? { promotionCodes: data.promotionCodes }
326
+ : {}),
327
+ });
328
+
329
+ if (!result.ok) {
330
+ throw new CommerceValidationError(
331
+ `Promotion application failed: ${result.error.message}`,
332
+ );
333
+ }
334
+
335
+ data.discountTotal = result.value.totalDiscount;
336
+ data.appliedPromotions = result.value.applied;
337
+ data.freeShipping = result.value.freeShipping;
338
+
339
+ recalculateTotals(data);
340
+ return data;
341
+ };
342
+
343
+ export const calculateTax: BeforeHook<CheckoutData> = async ({
344
+ data,
345
+ context,
346
+ }) => {
347
+ const tax = context.services.tax as {
348
+ calculate(input: {
349
+ currency: string;
350
+ customerId?: string;
351
+ shippingAmount: number;
352
+ fromAddress?: ShippingAddress;
353
+ toAddress?: ShippingAddress;
354
+ lineItems: Array<{
355
+ id: string;
356
+ entityId: string;
357
+ description: string;
358
+ quantity: number;
359
+ unitPrice: number;
360
+ discount?: number;
361
+ }>;
362
+ }): Promise<
363
+ | { ok: true; value: { amountToCollect: number } }
364
+ | { ok: false; error: Error }
365
+ >;
366
+ };
367
+
368
+ const calculated = await tax.calculate({
369
+ currency: data.currency,
370
+ shippingAmount: data.shippingTotal,
371
+ lineItems: data.lineItems.map((lineItem) => ({
372
+ id: lineItem.id ?? `${lineItem.entityId}:${lineItem.variantId ?? "_"}`,
373
+ entityId: lineItem.entityId,
374
+ description: lineItem.title ?? lineItem.entityId,
375
+ quantity: lineItem.quantity,
376
+ unitPrice: lineItem.resolvedUnitPrice ?? 0,
377
+ ...(lineItem.discountAmount !== undefined
378
+ ? { discount: lineItem.discountAmount }
379
+ : {}),
380
+ })),
381
+ ...(data.customerId !== undefined ? { customerId: data.customerId } : {}),
382
+ ...(data.shippingAddress !== undefined
383
+ ? { toAddress: data.shippingAddress }
384
+ : {}),
385
+ });
386
+
387
+ if (!calculated.ok) {
388
+ throw new CommerceValidationError(
389
+ `Tax calculation failed: ${calculated.error.message}`,
390
+ );
391
+ }
392
+
393
+ data.taxTotal = Math.max(0, Math.round(calculated.value.amountToCollect));
394
+ recalculateTotals(data);
395
+ return data;
396
+ };
397
+
398
+ export const calculateShipping: BeforeHook<CheckoutData> = async ({
399
+ data,
400
+ context,
401
+ }) => {
402
+ const shippingService = context.services.shipping as {
403
+ calculate(input: {
404
+ lineItems: Array<{
405
+ entityId: string;
406
+ variantId?: string;
407
+ quantity: number;
408
+ resolvedTotal: number;
409
+ }>;
410
+ subtotalAfterDiscount: number;
411
+ currency: string;
412
+ address?: ShippingAddress;
413
+ isFreeShipping?: boolean;
414
+ }): Promise<
415
+ | {
416
+ ok: true;
417
+ value: { amount: number; strategy: string; weightGrams: number };
418
+ }
419
+ | { ok: false; error: Error }
420
+ >;
421
+ };
422
+
423
+ const shipping = await shippingService.calculate({
424
+ lineItems: data.lineItems.map((lineItem) => ({
425
+ entityId: lineItem.entityId,
426
+ quantity: lineItem.quantity,
427
+ resolvedTotal: lineItem.resolvedTotal ?? 0,
428
+ ...(lineItem.variantId !== undefined
429
+ ? { variantId: lineItem.variantId }
430
+ : {}),
431
+ })),
432
+ subtotalAfterDiscount: Math.max(0, data.subtotal - data.discountTotal),
433
+ currency: data.currency,
434
+ ...(data.shippingAddress !== undefined
435
+ ? { address: data.shippingAddress }
436
+ : {}),
437
+ ...(data.freeShipping !== undefined
438
+ ? { isFreeShipping: data.freeShipping }
439
+ : {}),
440
+ });
441
+
442
+ if (!shipping.ok) {
443
+ throw new CommerceValidationError(
444
+ `Shipping calculation failed: ${shipping.error.message}`,
445
+ );
446
+ }
447
+
448
+ data.shippingTotal = shipping.value.amount;
449
+ recalculateTotals(data);
450
+ return data;
451
+ };
452
+
453
+ export const validatePaymentMethod: BeforeHook<CheckoutData> = async ({
454
+ data,
455
+ context,
456
+ }) => {
457
+ if (!data.paymentMethodId) {
458
+ throw new CommerceValidationError("Payment method is required.");
459
+ }
460
+
461
+ // H1 fix: Validate paymentMethodId against registered adapters
462
+ const payments = context.services.payments as {
463
+ registeredProviderIds?: string[];
464
+ };
465
+ if (
466
+ payments.registeredProviderIds &&
467
+ payments.registeredProviderIds.length > 0 &&
468
+ !payments.registeredProviderIds.includes(data.paymentMethodId)
469
+ ) {
470
+ throw new CommerceValidationError(
471
+ `Unknown payment method "${data.paymentMethodId}". ` +
472
+ `Available methods: [${payments.registeredProviderIds.join(", ")}].`,
473
+ );
474
+ }
475
+
476
+ return data;
477
+ };
478
+
479
+ export const authorizePayment: BeforeHook<CheckoutData> = async ({
480
+ data,
481
+ context,
482
+ }) => {
483
+ const payments = context.services.payments as {
484
+ authorize(input: {
485
+ amount: number;
486
+ currency: string;
487
+ paymentMethodId: string;
488
+ customerId?: string;
489
+ metadata: Record<string, unknown>;
490
+ }): Promise<{
491
+ ok: boolean;
492
+ value?: { id: string; clientSecret?: string | null };
493
+ error?: { message: string };
494
+ }>;
495
+ };
496
+ const authorized = await payments.authorize({
497
+ amount: data.total,
498
+ currency: data.currency,
499
+ paymentMethodId: data.paymentMethodId,
500
+ metadata: {
501
+ checkoutId: data.checkoutId,
502
+ cartId: data.cartId,
503
+ },
504
+ ...(data.customerId !== undefined ? { customerId: data.customerId } : {}),
505
+ });
506
+
507
+ if (!authorized.ok || !authorized.value) {
508
+ throw new CommerceValidationError(
509
+ `Payment authorization failed: ${authorized.error?.message ?? "Unknown payment error."}`,
510
+ );
511
+ }
512
+
513
+ data.paymentIntentId = authorized.value.id;
514
+ data.paymentClientSecret = authorized.value.clientSecret ?? undefined;
515
+ context.context.paymentIntentId = authorized.value.id;
516
+ return data;
517
+ };
518
+
519
+ export const capturePayment: AfterHook<OrderResult> = async ({ context }) => {
520
+ const payments = context.services.payments as {
521
+ capture(paymentIntentId: string, amount?: number, paymentMethodId?: string): Promise<unknown>;
522
+ };
523
+ const paymentIntentId = context.context.paymentIntentId as string | undefined;
524
+ const paymentMethodId = context.context.paymentMethodId as string | undefined;
525
+ if (!paymentIntentId) return;
526
+ await payments.capture(paymentIntentId, undefined, paymentMethodId);
527
+ };
528
+
529
+ export const reserveInventory: AfterHook<OrderResult> = async ({ result, context }) => {
530
+ const inventory = context.services.inventory as {
531
+ reserve(input: {
532
+ entityId: string;
533
+ variantId?: string;
534
+ quantity: number;
535
+ orderId: string;
536
+ performedBy: string;
537
+ }): Promise<unknown>;
538
+ };
539
+ for (const lineItem of result.lineItems ?? []) {
540
+ await inventory.reserve({
541
+ entityId: lineItem.entityId,
542
+ ...(lineItem.variantId != null ? { variantId: lineItem.variantId } : {}),
543
+ quantity: lineItem.quantity,
544
+ orderId: result.id,
545
+ performedBy: context.actor?.userId ?? "system",
546
+ });
547
+ }
548
+ };
549
+
550
+ export const initiateFulfillment: AfterHook<OrderResult> = async ({
551
+ result,
552
+ context,
553
+ }) => {
554
+ const fulfillment = context.services.fulfillment as {
555
+ fulfillOrder(orderId: string, actor?: unknown): Promise<unknown>;
556
+ };
557
+ await fulfillment.fulfillOrder(result.id, context.actor);
558
+ };
559
+
560
+ export const sendConfirmation: AfterHook<OrderResult> = async ({ result, context }) => {
561
+ const customers = context.services.customers as {
562
+ getByUserId(
563
+ userId: string,
564
+ actor?: unknown,
565
+ ): Promise<{ ok: boolean; value?: { email?: string } }>;
566
+ };
567
+ const email = context.services.email as
568
+ | {
569
+ send(input: {
570
+ template: string;
571
+ to: string;
572
+ data?: Record<string, unknown>;
573
+ }): Promise<void>;
574
+ }
575
+ | undefined;
576
+
577
+ if (!result.customerId || !email?.send) return;
578
+ const customer = await customers.getByUserId(result.customerId, context.actor);
579
+ if (!customer.ok || !customer.value?.email) return;
580
+
581
+ await email.send({
582
+ template: "order-confirmation",
583
+ to: customer.value.email,
584
+ data: { order: result },
585
+ });
586
+ };
587
+
588
+ /**
589
+ * Analytics event recording — no-op since RFC-006.
590
+ *
591
+ * The DrizzleAnalyticsAdapter queries source tables (orders, inventory)
592
+ * directly via SQL. No separate event recording is needed.
593
+ * Kept as an export for backwards compatibility with checkout route.
594
+ */
595
+ export const recordAnalyticsEvent: AfterHook<OrderResult> = async () => {
596
+ // No-op: source tables ARE the analytics events (RFC-006)
597
+ };
598
+
599
+ /**
600
+ * Replaces the separate capturePayment and reserveInventory AfterHooks
601
+ * with a single compensation chain that can roll back completed steps
602
+ * if any step fails.
603
+ *
604
+ * Order of steps:
605
+ * 1. Reserve inventory — if this fails, no money is charged
606
+ * 2. Capture payment — if this fails, inventory reservations are released
607
+ * 3. Initiate fulfillment — best-effort, does not fail the chain
608
+ * 4. Send confirmation — best-effort, does not fail the chain
609
+ *
610
+ * Both failure modes leave the system in a consistent state.
611
+ */
612
+ export const completeCheckout: AfterHook<OrderResult> = async ({
613
+ result: order,
614
+ context,
615
+ }) => {
616
+ const paymentIntentId = context.context.paymentIntentId as
617
+ | string
618
+ | undefined;
619
+
620
+ const checkoutData: CheckoutData = {
621
+ checkoutId: order.id,
622
+ cartId: (order.metadata?.cartId as string) ?? "",
623
+ ...(order.customerId != null ? { customerId: order.customerId } : {}),
624
+ currency: order.currency,
625
+ paymentMethodId: (context.context.paymentMethodId as string) ?? "",
626
+ lineItems: (order.lineItems ?? []).map((li) => ({
627
+ id: li.entityId,
628
+ entityId: li.entityId,
629
+ ...(li.entityType != null ? { entityType: li.entityType } : {}),
630
+ title: li.title ?? li.entityId,
631
+ ...(li.variantId != null ? { variantId: li.variantId } : {}),
632
+ quantity: li.quantity,
633
+ ...(li.unitPrice != null ? { resolvedUnitPrice: li.unitPrice } : {}),
634
+ ...(li.totalPrice != null ? { resolvedTotal: li.totalPrice } : {}),
635
+ })),
636
+ subtotal: order.subtotal ?? 0,
637
+ discountTotal: order.discountTotal ?? 0,
638
+ taxTotal: order.taxTotal ?? 0,
639
+ shippingTotal: order.shippingTotal ?? 0,
640
+ total: order.grandTotal ?? 0,
641
+ ...(paymentIntentId != null ? { paymentIntentId } : {}),
642
+ };
643
+
644
+ const compensationCtx: CompensationContext = {
645
+ tx: (context.tx as TxContext | null) ?? null,
646
+ hook: context,
647
+ };
648
+
649
+ const chainResult = await runCompensationChain(
650
+ [
651
+ reserveInventoryStep,
652
+ capturePaymentStep,
653
+ initiateFulfillmentStep,
654
+ sendConfirmationStep,
655
+ ],
656
+ checkoutData,
657
+ compensationCtx,
658
+ );
659
+
660
+ if (!chainResult.ok) {
661
+ // Mark order as failed if compensation chain did not succeed
662
+ const orders = context.services.orders as {
663
+ changeStatus?(input: { orderId: string; newStatus: string }, actor: unknown): Promise<unknown>;
664
+ };
665
+ if (orders.changeStatus) {
666
+ try {
667
+ await orders.changeStatus({ orderId: order.id, newStatus: "cancelled" }, context.actor);
668
+ } catch (statusError) {
669
+ context.logger.error(
670
+ `Failed to update order ${order.id} status to cancelled after checkout failure.`,
671
+ { statusError },
672
+ );
673
+ }
674
+ }
675
+ throw chainResult.error;
676
+ }
677
+ };