@porulle/core 0.7.0 → 0.9.0

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 (325) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +26 -7
  3. package/dist/auth/setup.d.ts.map +1 -1
  4. package/dist/auth/setup.js +8 -5
  5. package/dist/config/defaults.d.ts.map +1 -1
  6. package/dist/config/defaults.js +7 -0
  7. package/dist/config/types.d.ts +26 -1
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/generated/plugin-manifest.d.ts +3 -0
  10. package/dist/generated/plugin-manifest.d.ts.map +1 -1
  11. package/dist/generated/plugin-manifest.js +1 -0
  12. package/dist/hooks/checkout-completion.d.ts.map +1 -1
  13. package/dist/hooks/checkout-completion.js +2 -2
  14. package/dist/hooks/checkout.d.ts.map +1 -1
  15. package/dist/hooks/checkout.js +17 -2
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/interfaces/rest/index.d.ts.map +1 -1
  20. package/dist/interfaces/rest/index.js +12 -0
  21. package/dist/interfaces/rest/routes/admin/staff.d.ts +12 -0
  22. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -0
  23. package/dist/interfaces/rest/routes/admin/staff.js +206 -0
  24. package/dist/interfaces/rest/routes/analytics.d.ts +5 -0
  25. package/dist/interfaces/rest/routes/analytics.d.ts.map +1 -0
  26. package/dist/interfaces/rest/routes/analytics.js +24 -0
  27. package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
  28. package/dist/interfaces/rest/routes/carts.js +39 -2
  29. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  30. package/dist/interfaces/rest/routes/catalog.js +17 -1
  31. package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
  32. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  33. package/dist/interfaces/rest/routes/checkout.js +97 -21
  34. package/dist/interfaces/rest/routes/documents.d.ts +10 -0
  35. package/dist/interfaces/rest/routes/documents.d.ts.map +1 -0
  36. package/dist/interfaces/rest/routes/documents.js +40 -0
  37. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  38. package/dist/interfaces/rest/routes/inventory.js +13 -0
  39. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  40. package/dist/interfaces/rest/routes/orders.js +111 -1
  41. package/dist/interfaces/rest/routes/pricing.d.ts.map +1 -1
  42. package/dist/interfaces/rest/routes/pricing.js +36 -1
  43. package/dist/interfaces/rest/routes/settings.d.ts +5 -0
  44. package/dist/interfaces/rest/routes/settings.d.ts.map +1 -0
  45. package/dist/interfaces/rest/routes/settings.js +50 -0
  46. package/dist/interfaces/rest/routes/shipping.d.ts +5 -0
  47. package/dist/interfaces/rest/routes/shipping.d.ts.map +1 -0
  48. package/dist/interfaces/rest/routes/shipping.js +68 -0
  49. package/dist/interfaces/rest/routes/tax.d.ts +5 -0
  50. package/dist/interfaces/rest/routes/tax.d.ts.map +1 -0
  51. package/dist/interfaces/rest/routes/tax.js +68 -0
  52. package/dist/interfaces/rest/schemas/admin-staff.d.ts +899 -0
  53. package/dist/interfaces/rest/schemas/admin-staff.d.ts.map +1 -0
  54. package/dist/interfaces/rest/schemas/admin-staff.js +98 -0
  55. package/dist/interfaces/rest/schemas/analytics.d.ts +252 -0
  56. package/dist/interfaces/rest/schemas/analytics.d.ts.map +1 -0
  57. package/dist/interfaces/rest/schemas/analytics.js +33 -0
  58. package/dist/interfaces/rest/schemas/carts.d.ts +656 -0
  59. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  60. package/dist/interfaces/rest/schemas/carts.js +38 -0
  61. package/dist/interfaces/rest/schemas/catalog.d.ts +1218 -1
  62. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  63. package/dist/interfaces/rest/schemas/catalog.js +48 -0
  64. package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
  65. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  66. package/dist/interfaces/rest/schemas/checkout.js +4 -0
  67. package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
  68. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  69. package/dist/interfaces/rest/schemas/documents.d.ts +509 -0
  70. package/dist/interfaces/rest/schemas/documents.d.ts.map +1 -0
  71. package/dist/interfaces/rest/schemas/documents.js +65 -0
  72. package/dist/interfaces/rest/schemas/orders.d.ts +3110 -43
  73. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  74. package/dist/interfaces/rest/schemas/orders.js +234 -1
  75. package/dist/interfaces/rest/schemas/pricing.d.ts +405 -0
  76. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  77. package/dist/interfaces/rest/schemas/pricing.js +70 -0
  78. package/dist/interfaces/rest/schemas/responses.d.ts +190 -0
  79. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  80. package/dist/interfaces/rest/schemas/search.d.ts +17 -0
  81. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  82. package/dist/interfaces/rest/schemas/settings.d.ts +397 -0
  83. package/dist/interfaces/rest/schemas/settings.d.ts.map +1 -0
  84. package/dist/interfaces/rest/schemas/settings.js +79 -0
  85. package/dist/interfaces/rest/schemas/shipping.d.ts +1093 -0
  86. package/dist/interfaces/rest/schemas/shipping.d.ts.map +1 -0
  87. package/dist/interfaces/rest/schemas/shipping.js +146 -0
  88. package/dist/interfaces/rest/schemas/tax.d.ts +1074 -0
  89. package/dist/interfaces/rest/schemas/tax.d.ts.map +1 -0
  90. package/dist/interfaces/rest/schemas/tax.js +132 -0
  91. package/dist/kernel/database/schema.d.ts +4 -0
  92. package/dist/kernel/database/schema.d.ts.map +1 -1
  93. package/dist/kernel/database/schema.js +8 -0
  94. package/dist/kernel/database/scoped-db.d.ts +2 -1
  95. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  96. package/dist/kernel/database/scoped-db.js +62 -1
  97. package/dist/kernel/hooks/types.d.ts +1 -1
  98. package/dist/kernel/hooks/types.d.ts.map +1 -1
  99. package/dist/kernel/plugin/manifest.d.ts +26 -0
  100. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  101. package/dist/kernel/plugin/manifest.js +17 -2
  102. package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
  103. package/dist/modules/analytics/drizzle-adapter.js +16 -3
  104. package/dist/modules/analytics/module.d.ts.map +1 -1
  105. package/dist/modules/analytics/module.js +6 -0
  106. package/dist/modules/analytics/reports.d.ts +58 -0
  107. package/dist/modules/analytics/reports.d.ts.map +1 -0
  108. package/dist/modules/analytics/reports.js +249 -0
  109. package/dist/modules/analytics/service.d.ts +10 -0
  110. package/dist/modules/analytics/service.d.ts.map +1 -1
  111. package/dist/modules/analytics/service.js +14 -0
  112. package/dist/modules/cart/repository/index.d.ts +7 -0
  113. package/dist/modules/cart/repository/index.d.ts.map +1 -1
  114. package/dist/modules/cart/repository/index.js +25 -1
  115. package/dist/modules/cart/schema.d.ts +17 -0
  116. package/dist/modules/cart/schema.d.ts.map +1 -1
  117. package/dist/modules/cart/schema.js +2 -0
  118. package/dist/modules/cart/schemas.d.ts +1 -0
  119. package/dist/modules/cart/schemas.d.ts.map +1 -1
  120. package/dist/modules/cart/schemas.js +1 -0
  121. package/dist/modules/cart/service.d.ts +32 -0
  122. package/dist/modules/cart/service.d.ts.map +1 -1
  123. package/dist/modules/cart/service.js +61 -2
  124. package/dist/modules/catalog/entity-service.d.ts +21 -0
  125. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  126. package/dist/modules/catalog/entity-service.js +166 -5
  127. package/dist/modules/catalog/schema.d.ts +34 -0
  128. package/dist/modules/catalog/schema.d.ts.map +1 -1
  129. package/dist/modules/catalog/schema.js +5 -0
  130. package/dist/modules/catalog/schemas.d.ts +3 -0
  131. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  132. package/dist/modules/catalog/schemas.js +3 -0
  133. package/dist/modules/catalog/service.d.ts +36 -0
  134. package/dist/modules/catalog/service.d.ts.map +1 -1
  135. package/dist/modules/catalog/service.js +6 -0
  136. package/dist/modules/customers/service.d.ts.map +1 -1
  137. package/dist/modules/customers/service.js +6 -0
  138. package/dist/modules/documents/module.d.ts +7 -0
  139. package/dist/modules/documents/module.d.ts.map +1 -0
  140. package/dist/modules/documents/module.js +12 -0
  141. package/dist/modules/documents/pdf.d.ts +25 -0
  142. package/dist/modules/documents/pdf.d.ts.map +1 -0
  143. package/dist/modules/documents/pdf.js +80 -0
  144. package/dist/modules/documents/render.d.ts +32 -0
  145. package/dist/modules/documents/render.d.ts.map +1 -0
  146. package/dist/modules/documents/render.js +190 -0
  147. package/dist/modules/documents/repository/index.d.ts +25 -0
  148. package/dist/modules/documents/repository/index.d.ts.map +1 -0
  149. package/dist/modules/documents/repository/index.js +47 -0
  150. package/dist/modules/documents/schema.d.ts +214 -0
  151. package/dist/modules/documents/schema.d.ts.map +1 -0
  152. package/dist/modules/documents/schema.js +42 -0
  153. package/dist/modules/documents/service.d.ts +51 -0
  154. package/dist/modules/documents/service.d.ts.map +1 -0
  155. package/dist/modules/documents/service.js +119 -0
  156. package/dist/modules/fulfillment/service.d.ts +18 -0
  157. package/dist/modules/fulfillment/service.d.ts.map +1 -1
  158. package/dist/modules/fulfillment/service.js +85 -1
  159. package/dist/modules/orders/repository/index.d.ts +18 -1
  160. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  161. package/dist/modules/orders/repository/index.js +86 -1
  162. package/dist/modules/orders/schema.d.ts +391 -0
  163. package/dist/modules/orders/schema.d.ts.map +1 -1
  164. package/dist/modules/orders/schema.js +56 -1
  165. package/dist/modules/orders/service.d.ts +102 -3
  166. package/dist/modules/orders/service.d.ts.map +1 -1
  167. package/dist/modules/orders/service.js +653 -29
  168. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  169. package/dist/modules/orders/stale-order-cleanup.js +13 -1
  170. package/dist/modules/pricing/repository/index.d.ts +5 -0
  171. package/dist/modules/pricing/repository/index.d.ts.map +1 -1
  172. package/dist/modules/pricing/repository/index.js +19 -0
  173. package/dist/modules/pricing/service.d.ts +18 -0
  174. package/dist/modules/pricing/service.d.ts.map +1 -1
  175. package/dist/modules/pricing/service.js +43 -5
  176. package/dist/modules/settings/module.d.ts +6 -0
  177. package/dist/modules/settings/module.d.ts.map +1 -0
  178. package/dist/modules/settings/module.js +11 -0
  179. package/dist/modules/settings/repository/index.d.ts +16 -0
  180. package/dist/modules/settings/repository/index.d.ts.map +1 -0
  181. package/dist/modules/settings/repository/index.js +41 -0
  182. package/dist/modules/settings/schema.d.ts +121 -0
  183. package/dist/modules/settings/schema.d.ts.map +1 -0
  184. package/dist/modules/settings/schema.js +24 -0
  185. package/dist/modules/settings/service.d.ts +26 -0
  186. package/dist/modules/settings/service.d.ts.map +1 -0
  187. package/dist/modules/settings/service.js +51 -0
  188. package/dist/modules/shipping/calculator.d.ts +5 -0
  189. package/dist/modules/shipping/calculator.d.ts.map +1 -1
  190. package/dist/modules/shipping/calculator.js +10 -0
  191. package/dist/modules/shipping/module.d.ts +5 -1
  192. package/dist/modules/shipping/module.d.ts.map +1 -1
  193. package/dist/modules/shipping/module.js +4 -1
  194. package/dist/modules/shipping/repository/index.d.ts +27 -2
  195. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  196. package/dist/modules/shipping/repository/index.js +105 -1
  197. package/dist/modules/shipping/schema.d.ts +419 -0
  198. package/dist/modules/shipping/schema.d.ts.map +1 -0
  199. package/dist/modules/shipping/schema.js +56 -0
  200. package/dist/modules/shipping/service.d.ts +54 -0
  201. package/dist/modules/shipping/service.d.ts.map +1 -1
  202. package/dist/modules/shipping/service.js +161 -2
  203. package/dist/modules/tax/adapter.d.ts +13 -0
  204. package/dist/modules/tax/adapter.d.ts.map +1 -1
  205. package/dist/modules/tax/module.d.ts +5 -1
  206. package/dist/modules/tax/module.d.ts.map +1 -1
  207. package/dist/modules/tax/module.js +4 -1
  208. package/dist/modules/tax/repository/index.d.ts +38 -2
  209. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  210. package/dist/modules/tax/repository/index.js +138 -1
  211. package/dist/modules/tax/schema.d.ts +355 -0
  212. package/dist/modules/tax/schema.d.ts.map +1 -0
  213. package/dist/modules/tax/schema.js +55 -0
  214. package/dist/modules/tax/service.d.ts +60 -1
  215. package/dist/modules/tax/service.d.ts.map +1 -1
  216. package/dist/modules/tax/service.js +227 -2
  217. package/dist/runtime/kernel-modules.d.ts +15 -2
  218. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  219. package/dist/runtime/kernel-modules.js +4 -0
  220. package/dist/runtime/kernel-types.d.ts +5 -1
  221. package/dist/runtime/kernel-types.d.ts.map +1 -1
  222. package/dist/runtime/kernel-types.js +2 -0
  223. package/dist/runtime/server.js +1 -1
  224. package/dist/test-utils/create-plugin-test-app.d.ts +3 -0
  225. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  226. package/dist/test-utils/create-plugin-test-app.js +15 -3
  227. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  228. package/dist/test-utils/create-test-config.js +2 -0
  229. package/dist/test-utils/order-test-helpers.d.ts +12 -0
  230. package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
  231. package/dist/test-utils/order-test-helpers.js +18 -0
  232. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  233. package/dist/test-utils/rest-api-test-utils.js +7 -0
  234. package/dist/test-utils/test-actors.js +1 -1
  235. package/dist/testing.d.ts +1 -0
  236. package/dist/testing.d.ts.map +1 -1
  237. package/dist/testing.js +1 -0
  238. package/package.json +3 -3
  239. package/src/auth/middleware.ts +26 -7
  240. package/src/auth/setup.ts +11 -4
  241. package/src/config/defaults.ts +7 -0
  242. package/src/config/types.ts +26 -1
  243. package/src/generated/plugin-capabilities.d.ts +1 -0
  244. package/src/generated/plugin-manifest.ts +1 -0
  245. package/src/generated/plugin-repositories.d.ts +1 -0
  246. package/src/hooks/checkout-completion.ts +22 -16
  247. package/src/hooks/checkout.ts +36 -11
  248. package/src/index.ts +1 -0
  249. package/src/interfaces/rest/index.ts +12 -0
  250. package/src/interfaces/rest/routes/admin/staff.ts +260 -0
  251. package/src/interfaces/rest/routes/analytics.ts +33 -0
  252. package/src/interfaces/rest/routes/carts.ts +52 -2
  253. package/src/interfaces/rest/routes/catalog.ts +34 -0
  254. package/src/interfaces/rest/routes/checkout.ts +114 -27
  255. package/src/interfaces/rest/routes/documents.ts +61 -0
  256. package/src/interfaces/rest/routes/inventory.ts +14 -0
  257. package/src/interfaces/rest/routes/orders.ts +148 -1
  258. package/src/interfaces/rest/routes/pricing.ts +44 -1
  259. package/src/interfaces/rest/routes/settings.ts +61 -0
  260. package/src/interfaces/rest/routes/shipping.ts +92 -0
  261. package/src/interfaces/rest/routes/tax.ts +90 -0
  262. package/src/interfaces/rest/schemas/admin-staff.ts +110 -0
  263. package/src/interfaces/rest/schemas/analytics.ts +39 -0
  264. package/src/interfaces/rest/schemas/carts.ts +40 -0
  265. package/src/interfaces/rest/schemas/catalog.ts +53 -0
  266. package/src/interfaces/rest/schemas/checkout.ts +4 -0
  267. package/src/interfaces/rest/schemas/documents.ts +72 -0
  268. package/src/interfaces/rest/schemas/orders.ts +258 -1
  269. package/src/interfaces/rest/schemas/pricing.ts +75 -0
  270. package/src/interfaces/rest/schemas/settings.ts +93 -0
  271. package/src/interfaces/rest/schemas/shipping.ts +164 -0
  272. package/src/interfaces/rest/schemas/tax.ts +148 -0
  273. package/src/kernel/database/schema.ts +12 -0
  274. package/src/kernel/database/scoped-db.ts +70 -1
  275. package/src/kernel/hooks/types.ts +1 -0
  276. package/src/kernel/plugin/manifest.ts +41 -2
  277. package/src/modules/analytics/drizzle-adapter.ts +19 -3
  278. package/src/modules/analytics/module.ts +10 -0
  279. package/src/modules/analytics/reports.ts +311 -0
  280. package/src/modules/analytics/service.ts +23 -0
  281. package/src/modules/cart/repository/index.ts +30 -1
  282. package/src/modules/cart/schema.ts +2 -0
  283. package/src/modules/cart/schemas.ts +1 -0
  284. package/src/modules/cart/service.ts +104 -2
  285. package/src/modules/catalog/entity-service.ts +196 -4
  286. package/src/modules/catalog/schema.ts +5 -0
  287. package/src/modules/catalog/schemas.ts +3 -0
  288. package/src/modules/catalog/service.ts +30 -0
  289. package/src/modules/customers/service.ts +6 -0
  290. package/src/modules/documents/module.ts +19 -0
  291. package/src/modules/documents/pdf.ts +101 -0
  292. package/src/modules/documents/render.ts +231 -0
  293. package/src/modules/documents/repository/index.ts +68 -0
  294. package/src/modules/documents/schema.ts +44 -0
  295. package/src/modules/documents/service.ts +176 -0
  296. package/src/modules/fulfillment/service.ts +139 -1
  297. package/src/modules/orders/repository/index.ts +127 -1
  298. package/src/modules/orders/schema.ts +58 -1
  299. package/src/modules/orders/service.ts +1041 -65
  300. package/src/modules/orders/stale-order-cleanup.ts +18 -4
  301. package/src/modules/pricing/repository/index.ts +34 -0
  302. package/src/modules/pricing/service.ts +76 -6
  303. package/src/modules/settings/module.ts +18 -0
  304. package/src/modules/settings/repository/index.ts +59 -0
  305. package/src/modules/settings/schema.ts +25 -0
  306. package/src/modules/settings/service.ts +82 -0
  307. package/src/modules/shipping/calculator.ts +27 -0
  308. package/src/modules/shipping/module.ts +6 -2
  309. package/src/modules/shipping/repository/index.ts +136 -2
  310. package/src/modules/shipping/schema.ts +58 -0
  311. package/src/modules/shipping/service.ts +260 -1
  312. package/src/modules/tax/adapter.ts +9 -0
  313. package/src/modules/tax/module.ts +6 -2
  314. package/src/modules/tax/repository/index.ts +169 -2
  315. package/src/modules/tax/schema.ts +57 -0
  316. package/src/modules/tax/service.ts +313 -2
  317. package/src/runtime/kernel-modules.ts +4 -0
  318. package/src/runtime/kernel-types.ts +6 -0
  319. package/src/runtime/server.ts +1 -1
  320. package/src/test-utils/create-plugin-test-app.ts +17 -4
  321. package/src/test-utils/create-test-config.ts +2 -0
  322. package/src/test-utils/order-test-helpers.ts +26 -0
  323. package/src/test-utils/rest-api-test-utils.ts +7 -0
  324. package/src/test-utils/test-actors.ts +1 -1
  325. package/src/testing.ts +1 -0
@@ -2,6 +2,7 @@ import { resolveOrgId } from "../../auth/org.js";
2
2
  import { assertOwnership, assertPermission } from "../../auth/permissions.js";
3
3
  import type { Actor } from "../../auth/types.js";
4
4
  import {
5
+ CommerceForbiddenError,
5
6
  CommerceInvalidTransitionError,
6
7
  CommerceNotFoundError,
7
8
  CommerceValidationError,
@@ -24,18 +25,25 @@ import {
24
25
  import { Err, Ok, type Result } from "../../kernel/result.js";
25
26
  import { createLogger } from "../../utils/logger.js";
26
27
  import { paginate, type Pagination } from "../../utils/pagination.js";
27
- import type { TxContext } from "../../kernel/database/tx-context.js";
28
+ import {
29
+ withTransaction,
30
+ type TxContext,
31
+ } from "../../kernel/database/tx-context.js";
28
32
  import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
29
33
  import type { PluginDb } from "../../kernel/database/plugin-types.js";
30
34
  import {
31
35
  OrdersRepository,
32
36
  type Order,
33
37
  type OrderLineItem,
38
+ type OrderNote,
39
+ type OrderRefund,
34
40
  type OrderStatusHistory,
35
41
  } from "./repository/index.js";
36
42
 
37
43
  export interface CreateOrderInput {
38
44
  customerId?: string;
45
+ /** Client-supplied retry key — a repeat create with the same key returns the original order. */
46
+ idempotencyKey?: string | undefined;
39
47
  currency: string;
40
48
  subtotal: number;
41
49
  taxTotal: number;
@@ -144,11 +152,128 @@ export class OrderService {
144
152
  return { ...order, lineItems };
145
153
  }
146
154
 
155
+ /**
156
+ * Resolve every line item's price with org-integrity + price provenance,
157
+ * mirroring how our forefathers handle this (Vendure: order lines are always
158
+ * server-priced from the variant; Medusa: an admin override is honored but
159
+ * flagged `is_custom_price`). Three modes:
160
+ * - trusted caller (checkout / POS exchange, already server-priced) → prices honored as-is.
161
+ * - actor with `orders:manage` → explicit price honored as a manual override, flagged isCustomPrice.
162
+ * - otherwise → price re-derived server-side from the catalog; client prices ignored.
163
+ * The referenced entity MUST belong to the order's org in every mode.
164
+ */
165
+ private async resolveLines(
166
+ input: CreateOrderInput,
167
+ actor: Actor | null,
168
+ trusted: boolean,
169
+ ctx: TxContext | undefined,
170
+ ): Promise<Result<Array<CreateOrderInput["lineItems"][number] & { isCustomPrice: boolean }>>> {
171
+ let canOverride = trusted;
172
+ if (!canOverride) {
173
+ try {
174
+ assertPermission(actor, "orders:manage");
175
+ canOverride = true;
176
+ } catch {
177
+ canOverride = false;
178
+ }
179
+ }
180
+ const catalog = this.deps.services.catalog as {
181
+ getById(
182
+ id: string,
183
+ options: Record<string, unknown>,
184
+ actor?: Actor | null,
185
+ ctx?: TxContext,
186
+ ): Promise<{
187
+ ok: boolean;
188
+ value?: { variants?: Array<{ id: string }> };
189
+ }>;
190
+ };
191
+ const pricing = this.deps.services.pricing as {
192
+ resolve(
193
+ input: {
194
+ entityId: string;
195
+ variantId?: string;
196
+ currency: string;
197
+ quantity: number;
198
+ customerId?: string;
199
+ },
200
+ actor?: Actor | null,
201
+ ctx?: TxContext,
202
+ ): Promise<{ ok: boolean; value?: { finalAmount: number } }>;
203
+ };
204
+ const resolved: Array<
205
+ CreateOrderInput["lineItems"][number] & { isCustomPrice: boolean }
206
+ > = [];
207
+ for (const item of input.lineItems) {
208
+ // Org-integrity — the referenced entity must belong to this order's org.
209
+ const owned = await catalog.getById(
210
+ item.entityId,
211
+ { includeVariants: item.variantId !== undefined },
212
+ actor,
213
+ ctx,
214
+ );
215
+ if (!owned.ok) {
216
+ return Err(
217
+ new CommerceValidationError(
218
+ `Line item entity ${item.entityId} does not belong to this organization.`,
219
+ ),
220
+ );
221
+ }
222
+ if (
223
+ item.variantId !== undefined &&
224
+ !owned.value?.variants?.some((variant) => variant.id === item.variantId)
225
+ ) {
226
+ return Err(
227
+ new CommerceValidationError(
228
+ `Line item variant ${item.variantId} does not belong to entity ${item.entityId}.`,
229
+ ),
230
+ );
231
+ }
232
+ if (trusted || canOverride) {
233
+ // Trusted pipeline totals, or a staff manual/negotiated override.
234
+ resolved.push({ ...item, isCustomPrice: !trusted && canOverride });
235
+ continue;
236
+ }
237
+ // Server-derive the price; the client-supplied unitPrice/totalPrice is ignored.
238
+ const priced = await pricing.resolve(
239
+ {
240
+ entityId: item.entityId,
241
+ currency: input.currency,
242
+ quantity: item.quantity,
243
+ ...(item.variantId ? { variantId: item.variantId } : {}),
244
+ ...(input.customerId ? { customerId: input.customerId } : {}),
245
+ },
246
+ actor,
247
+ ctx,
248
+ );
249
+ if (!priced.ok || !priced.value) {
250
+ return Err(
251
+ new CommerceValidationError(`Cannot resolve price for ${item.entityId}.`),
252
+ );
253
+ }
254
+ const unitPrice = priced.value.finalAmount;
255
+ resolved.push({
256
+ ...item,
257
+ unitPrice,
258
+ totalPrice: unitPrice * item.quantity,
259
+ isCustomPrice: false,
260
+ });
261
+ }
262
+ return Ok(resolved);
263
+ }
264
+
147
265
  async create(
148
266
  input: CreateOrderInput,
149
267
  actor: Actor | null,
150
268
  ctx?: TxContext,
269
+ opts?: { trustedPricing?: boolean; stockPolicy?: "reserve" | "backorder" },
151
270
  ): Promise<Result<HydratedOrder>> {
271
+ if (opts?.stockPolicy === "reserve" && !ctx) {
272
+ return withTransaction(this.deps.database, { actor }, (txCtx) =>
273
+ this.create(input, actor, txCtx, opts),
274
+ );
275
+ }
276
+
152
277
  try {
153
278
  assertPermission(actor, "orders:create");
154
279
  } catch (error) {
@@ -176,39 +301,124 @@ export class OrderService {
176
301
  hookCtx,
177
302
  );
178
303
 
179
- const orderNumber = await this.repo.getNextOrderNumber(ctx);
180
304
  const orgId = resolveOrgId(actor);
181
305
 
182
- const order = await this.repo.create(
183
- {
184
- organizationId: orgId,
185
- orderNumber,
186
- status: "pending",
187
- currency: processed.currency,
188
- subtotal: processed.subtotal,
189
- taxTotal: processed.taxTotal,
190
- shippingTotal: processed.shippingTotal,
191
- discountTotal: processed.discountTotal ?? 0,
192
- grandTotal: processed.grandTotal,
193
- ...(processed.paymentIntentId != null ? { paymentIntentId: processed.paymentIntentId } : {}),
194
- ...(processed.paymentMethodId != null ? { paymentMethodId: processed.paymentMethodId } : {}),
195
- metadata: processed.metadata ?? {},
196
- placedAt: new Date(),
197
- ...(processed.customerId !== undefined
198
- ? { customerId: processed.customerId }
199
- : {}),
200
- },
201
- ctx,
202
- );
306
+ // Idempotent replay: offline POS queues and network retries re-submit the
307
+ // same create — return the original order instead of double-creating.
308
+ if (processed.idempotencyKey) {
309
+ const existing = await this.repo.findByIdempotencyKey(
310
+ orgId,
311
+ processed.idempotencyKey,
312
+ ctx,
313
+ );
314
+ if (existing) {
315
+ return Ok(await this.hydrateOrder(existing, ctx));
316
+ }
317
+ }
318
+
319
+ // Resolve line prices with org-integrity + provenance (see resolveLines).
320
+ const trusted = opts?.trustedPricing === true;
321
+ const resolvedResult = await this.resolveLines(processed, actor, trusted, ctx);
322
+ if (!resolvedResult.ok) return Err(resolvedResult.error);
323
+ const resolvedLines = resolvedResult.value;
324
+
325
+ // Order totals: trusted callers (checkout / exchange) supply rich totals
326
+ // (promotions, tax, shipping); otherwise derive from the server-priced lines.
327
+ const computedSubtotal = trusted
328
+ ? processed.subtotal
329
+ : resolvedLines.reduce(
330
+ (sum, l) => sum + (l.totalPrice ?? l.unitPrice * l.quantity),
331
+ 0,
332
+ );
333
+ const computedGrandTotal = trusted
334
+ ? processed.grandTotal
335
+ : computedSubtotal +
336
+ processed.taxTotal +
337
+ processed.shippingTotal -
338
+ (processed.discountTotal ?? 0);
339
+
340
+ // Stock: order creation does NOT reserve by default. Stock is allocated at
341
+ // the lifecycle transition to a committed state — checkout reserves in its
342
+ // own pipeline, mirroring Vendure's StockAllocationStrategy (allocate on
343
+ // PaymentAuthorized, not on creation). A caller that represents an immediate
344
+ // committed sale (e.g. a POS exchange) opts in with stockPolicy: "reserve".
345
+ const stockPolicy = opts?.stockPolicy;
346
+ if (stockPolicy === "reserve") {
347
+ const inventory = this.deps.services.inventory as {
348
+ getAvailable(
349
+ entityId: string,
350
+ variantId: string | undefined,
351
+ ctx?: TxContext,
352
+ actor?: Actor | null,
353
+ ): Promise<{ ok: boolean; value?: number }>;
354
+ };
355
+ for (const line of resolvedLines) {
356
+ const avail = await inventory.getAvailable(
357
+ line.entityId,
358
+ line.variantId,
359
+ ctx,
360
+ actor,
361
+ );
362
+ if (!avail.ok || (avail.value ?? 0) < line.quantity) {
363
+ return Err(
364
+ new CommerceValidationError(
365
+ `Insufficient stock for ${line.title ?? line.entityId}. Available: ${
366
+ avail.ok ? (avail.value ?? 0) : 0
367
+ }, requested: ${line.quantity}.`,
368
+ ),
369
+ );
370
+ }
371
+ }
372
+ }
373
+
374
+ const orderNumber = await this.repo.getNextOrderNumber(ctx);
375
+
376
+ let order: Order;
377
+ try {
378
+ order = await this.repo.create(
379
+ {
380
+ organizationId: orgId,
381
+ orderNumber,
382
+ status: "pending",
383
+ currency: processed.currency,
384
+ subtotal: computedSubtotal,
385
+ taxTotal: processed.taxTotal,
386
+ shippingTotal: processed.shippingTotal,
387
+ discountTotal: processed.discountTotal ?? 0,
388
+ grandTotal: computedGrandTotal,
389
+ ...(processed.paymentIntentId != null ? { paymentIntentId: processed.paymentIntentId } : {}),
390
+ ...(processed.paymentMethodId != null ? { paymentMethodId: processed.paymentMethodId } : {}),
391
+ ...(processed.idempotencyKey != null ? { idempotencyKey: processed.idempotencyKey } : {}),
392
+ metadata: processed.metadata ?? {},
393
+ placedAt: new Date(),
394
+ ...(processed.customerId !== undefined
395
+ ? { customerId: processed.customerId }
396
+ : {}),
397
+ },
398
+ ctx,
399
+ );
400
+ } catch (error) {
401
+ // Concurrent replay lost the unique-index race — return the winner.
402
+ if (processed.idempotencyKey) {
403
+ const winner = await this.repo.findByIdempotencyKey(
404
+ orgId,
405
+ processed.idempotencyKey,
406
+ ctx,
407
+ );
408
+ if (winner) return Ok(await this.hydrateOrder(winner, ctx));
409
+ }
410
+ throw error;
411
+ }
203
412
 
204
- const lineItemsData = processed.lineItems.map((item) => ({
413
+ const lineItemsData = resolvedLines.map((item) => ({
205
414
  orderId: order.id,
206
415
  entityId: item.entityId,
207
416
  entityType: item.entityType,
208
417
  title: item.title,
209
418
  quantity: item.quantity,
210
419
  unitPrice: item.unitPrice,
211
- totalPrice: item.totalPrice,
420
+ totalPrice: item.totalPrice ?? item.unitPrice * item.quantity,
421
+ isCustomPrice: item.isCustomPrice,
212
422
  taxAmount: item.taxAmount ?? 0,
213
423
  discountAmount: item.discountAmount ?? 0,
214
424
  fulfillmentStatus: "unfulfilled" as const,
@@ -219,6 +429,45 @@ export class OrderService {
219
429
 
220
430
  await this.repo.createLineItems(lineItemsData, ctx);
221
431
 
432
+ // Reserve stock for the resolved lines when the reserve policy is active
433
+ // (availability was verified above). Trusted callers reserve themselves.
434
+ if (stockPolicy === "reserve") {
435
+ const inventory = this.deps.services.inventory as {
436
+ reserve(
437
+ input: {
438
+ entityId: string;
439
+ quantity: number;
440
+ orderId: string;
441
+ variantId?: string;
442
+ },
443
+ actor?: Actor | null,
444
+ ctx?: TxContext,
445
+ ): Promise<{ ok: boolean; error?: unknown }>;
446
+ };
447
+ for (const line of resolvedLines) {
448
+ const reserved = await inventory.reserve(
449
+ {
450
+ entityId: line.entityId,
451
+ quantity: line.quantity,
452
+ orderId: order.id,
453
+ ...(line.variantId ? { variantId: line.variantId } : {}),
454
+ },
455
+ actor,
456
+ ctx,
457
+ );
458
+ if (!reserved.ok) {
459
+ return Err(
460
+ toCommerceError(
461
+ reserved.error ??
462
+ new CommerceValidationError(
463
+ `Unable to reserve stock for ${line.title ?? line.entityId}.`,
464
+ ),
465
+ ),
466
+ );
467
+ }
468
+ }
469
+ }
470
+
222
471
  await this.repo.createStatusHistory(
223
472
  {
224
473
  orderId: order.id,
@@ -294,6 +543,18 @@ export class OrderService {
294
543
  return this.getById(order.id, actor, ctx);
295
544
  }
296
545
 
546
+ /** Returns the order previously created with this idempotency key, or null. */
547
+ async getByIdempotencyKey(
548
+ idempotencyKey: string,
549
+ actor?: Actor | null,
550
+ ctx?: TxContext,
551
+ ): Promise<Result<HydratedOrder | null>> {
552
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
553
+ const order = await this.repo.findByIdempotencyKey(orgId, idempotencyKey, ctx);
554
+ if (!order) return Ok(null);
555
+ return Ok(await this.hydrateOrder(order, ctx));
556
+ }
557
+
297
558
  async list(
298
559
  params: ListOrdersParams,
299
560
  actor: Actor | null,
@@ -472,6 +733,19 @@ export class OrderService {
472
733
  const previous = order.status;
473
734
  const lineItems = await this.repo.findLineItemsByOrderId(order.id, ctx);
474
735
 
736
+ // R-04: goods must not ship against refunded lines. Reject the fulfilled
737
+ // transition if any line has been (partially) refunded.
738
+ if (
739
+ input.newStatus === "fulfilled" &&
740
+ lineItems.some((li) => li.refundedQuantity > 0)
741
+ ) {
742
+ return Err(
743
+ new CommerceValidationError(
744
+ "Cannot fulfill an order that has refunded line items.",
745
+ ),
746
+ );
747
+ }
748
+
475
749
  // VAPT r2 (codex) finding: side effects (inventory release, payment
476
750
  // refund, tax void) used to run BEFORE the atomic compare-and-swap on
477
751
  // updateStatus. Two parallel cancel/refund requests on the same order
@@ -499,13 +773,17 @@ export class OrderService {
499
773
  // 1. Release inventory reservations
500
774
  const inventory = this.deps.services.inventory as
501
775
  | {
502
- release(input: {
503
- entityId: string;
504
- variantId?: string;
505
- quantity: number;
506
- orderId: string;
507
- performedBy?: string;
508
- }): Promise<unknown>;
776
+ release(
777
+ input: {
778
+ entityId: string;
779
+ variantId?: string;
780
+ quantity: number;
781
+ orderId: string;
782
+ performedBy?: string;
783
+ },
784
+ actor?: Actor | null,
785
+ ctx?: TxContext,
786
+ ): Promise<Result<void>>;
509
787
  }
510
788
  | undefined;
511
789
 
@@ -515,15 +793,30 @@ export class OrderService {
515
793
  // Fulfilled items had their reservation released during the
516
794
  // fulfilled transition — releasing again would double-release.
517
795
  if (lineItem.fulfillmentStatus === "unfulfilled") {
518
- await inventory.release({
519
- entityId: lineItem.entityId,
520
- quantity: lineItem.quantity,
521
- orderId: order.id,
522
- performedBy: actor?.userId ?? "system",
523
- ...(lineItem.variantId != null
524
- ? { variantId: lineItem.variantId }
525
- : {}),
526
- });
796
+ const releaseResult = await inventory.release(
797
+ {
798
+ entityId: lineItem.entityId,
799
+ quantity: lineItem.quantity,
800
+ orderId: order.id,
801
+ performedBy: actor?.userId ?? "system",
802
+ ...(lineItem.variantId != null
803
+ ? { variantId: lineItem.variantId }
804
+ : {}),
805
+ },
806
+ actor,
807
+ ctx,
808
+ );
809
+ if (!releaseResult.ok) {
810
+ const err = toCommerceError(releaseResult.error);
811
+ if (
812
+ !(
813
+ err instanceof CommerceValidationError &&
814
+ err.message === "No inventory record found for this entity."
815
+ )
816
+ ) {
817
+ return Err(err);
818
+ }
819
+ }
527
820
  }
528
821
  }
529
822
  }
@@ -545,19 +838,27 @@ export class OrderService {
545
838
  | undefined;
546
839
 
547
840
  if (payments?.refund) {
548
- const maxRefund = Math.min(
549
- order.grandTotal,
550
- order.amountCaptured ?? order.grandTotal,
841
+ // R-01: subtract prior gateway refunds (line-level refundLines, and
842
+ // undone refunds — the gateway was never reversed) so this order-level
843
+ // refund can't double-pay on top of what has already been refunded.
844
+ const priorRefunds = await this.repo.findRefundsByOrderId(order.id, ctx);
845
+ const alreadyRefunded = priorRefunds.reduce((sum, r) => sum + r.amount, 0);
846
+ const maxRefund = Math.max(
847
+ 0,
848
+ Math.min(order.grandTotal, order.amountCaptured ?? order.grandTotal) -
849
+ alreadyRefunded,
551
850
  );
552
851
  const refundAmount =
553
852
  input.refundAmount != null
554
853
  ? Math.min(input.refundAmount, maxRefund)
555
854
  : maxRefund;
556
- await payments.refund(
557
- paymentIntentId,
558
- refundAmount,
559
- input.reason ?? `order_${input.newStatus}`,
560
- );
855
+ if (refundAmount > 0) {
856
+ await payments.refund(
857
+ paymentIntentId,
858
+ refundAmount,
859
+ input.reason ?? `order_${input.newStatus}`,
860
+ );
861
+ }
561
862
  }
562
863
  }
563
864
 
@@ -590,13 +891,17 @@ export class OrderService {
590
891
  orderId: string;
591
892
  orgId?: string;
592
893
  }): Promise<unknown>;
593
- release(input: {
594
- entityId: string;
595
- variantId?: string;
596
- quantity: number;
597
- orderId: string;
598
- performedBy?: string;
599
- }): Promise<unknown>;
894
+ release(
895
+ input: {
896
+ entityId: string;
897
+ variantId?: string;
898
+ quantity: number;
899
+ orderId: string;
900
+ performedBy?: string;
901
+ },
902
+ actor?: Actor | null,
903
+ ctx?: TxContext,
904
+ ): Promise<Result<void>>;
600
905
  }
601
906
  | undefined;
602
907
 
@@ -615,15 +920,30 @@ export class OrderService {
615
920
  });
616
921
 
617
922
  // Release reservation (no longer needed)
618
- await inventory.release({
619
- entityId: lineItem.entityId,
620
- quantity: lineItem.quantity,
621
- orderId: order.id,
622
- performedBy: actor?.userId ?? "system",
623
- ...(lineItem.variantId != null
624
- ? { variantId: lineItem.variantId }
625
- : {}),
626
- });
923
+ const releaseResult = await inventory.release(
924
+ {
925
+ entityId: lineItem.entityId,
926
+ quantity: lineItem.quantity,
927
+ orderId: order.id,
928
+ performedBy: actor?.userId ?? "system",
929
+ ...(lineItem.variantId != null
930
+ ? { variantId: lineItem.variantId }
931
+ : {}),
932
+ },
933
+ actor,
934
+ ctx,
935
+ );
936
+ if (!releaseResult.ok) {
937
+ const err = toCommerceError(releaseResult.error);
938
+ if (
939
+ !(
940
+ err instanceof CommerceValidationError &&
941
+ err.message === "No inventory record found for this entity."
942
+ )
943
+ ) {
944
+ return Err(err);
945
+ }
946
+ }
627
947
 
628
948
  // Mark line item as fulfilled
629
949
  await this.repo.updateLineItem(
@@ -698,6 +1018,424 @@ export class OrderService {
698
1018
  );
699
1019
  }
700
1020
 
1021
+ // ── Line-level refund policy primitives (issue #52) ─────────────────────
1022
+
1023
+ private async refundPolicies(orgId: string, ctx?: TxContext): Promise<{
1024
+ cap: number | null;
1025
+ undoWindowMinutes: number;
1026
+ timezone: string;
1027
+ }> {
1028
+ const settings = this.deps.services.settings as
1029
+ | { read(orgId: string, group: string, ctx?: TxContext): Promise<Record<string, unknown>> }
1030
+ | undefined;
1031
+ const policies = (await settings?.read(orgId, "policies", ctx)) ?? {};
1032
+ const general = (await settings?.read(orgId, "general", ctx)) ?? {};
1033
+ return {
1034
+ cap: typeof policies.refundDailyCap === "number" ? policies.refundDailyCap : null,
1035
+ undoWindowMinutes:
1036
+ typeof policies.refundUndoWindowMinutes === "number"
1037
+ ? policies.refundUndoWindowMinutes
1038
+ : 15,
1039
+ timezone: typeof general.timezone === "string" ? general.timezone : "UTC",
1040
+ };
1041
+ }
1042
+
1043
+ /**
1044
+ * Refunds specific line-item quantities (issue #52). Enforces per-line
1045
+ * refundable quantity (`quantity - refundedQuantity`), the operator's daily
1046
+ * refund cap (`policies.refundDailyCap`, 403 with the cap surfaced), moves
1047
+ * money through the payment adapter when the order has a captured payment,
1048
+ * and records an auditable `order_refunds` ledger row.
1049
+ */
1050
+ async refundLines(
1051
+ orderId: string,
1052
+ input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string | undefined },
1053
+ actor: Actor | null,
1054
+ ctx?: TxContext,
1055
+ ): Promise<Result<{ order: HydratedOrder; refund: OrderRefund }>> {
1056
+ try {
1057
+ assertPermission(actor, "orders:update");
1058
+ } catch (error) {
1059
+ return Err(toCommerceError(error));
1060
+ }
1061
+ const orgId = resolveOrgId(actor);
1062
+ const found = await this.repo.findWithLineItems(orgId, orderId, ctx);
1063
+ if (!found) return Err(new CommerceNotFoundError("Order not found."));
1064
+ if (input.lines.length === 0) {
1065
+ return Err(new CommerceValidationError("At least one line is required."));
1066
+ }
1067
+ // R-02: a terminal order (cancelled / fully refunded) cannot be line-refunded.
1068
+ if (this.machine.terminal.includes(found.order.status)) {
1069
+ return Err(
1070
+ new CommerceValidationError(
1071
+ `Cannot refund an order in terminal status "${found.order.status}".`,
1072
+ ),
1073
+ );
1074
+ }
1075
+ // R-03: a refund moves collected money — reject an unpaid order (otherwise a
1076
+ // "refund" is recorded against funds never taken). "Paid" is either a core
1077
+ // captured payment (checkout: amountCaptured > 0) or the order having left
1078
+ // the initial pending state (e.g. a completed POS/in-store sale). Only a
1079
+ // still-pending, uncaptured order is treated as unpaid.
1080
+ if ((found.order.amountCaptured ?? 0) <= 0 && found.order.status === "pending") {
1081
+ return Err(
1082
+ new CommerceValidationError(
1083
+ "Cannot refund an unpaid order (no captured payment and still pending).",
1084
+ ),
1085
+ );
1086
+ }
1087
+
1088
+ const byId = new Map(found.lineItems.map((li) => [li.id, li]));
1089
+ const refundLines: Array<{ lineItemId: string; quantity: number; amount: number }> = [];
1090
+ for (const line of input.lines) {
1091
+ const lineItem = byId.get(line.lineItemId);
1092
+ if (!lineItem) {
1093
+ return Err(new CommerceNotFoundError(`Line item ${line.lineItemId} not found on this order.`));
1094
+ }
1095
+ if (!Number.isInteger(line.quantity) || line.quantity < 1) {
1096
+ return Err(new CommerceValidationError("Refund quantity must be a positive integer."));
1097
+ }
1098
+ const refundable = lineItem.quantity - lineItem.refundedQuantity;
1099
+ if (line.quantity > refundable) {
1100
+ return Err(
1101
+ new CommerceValidationError(
1102
+ `Line "${lineItem.title}" has ${refundable} refundable unit(s); requested ${line.quantity}.`,
1103
+ ),
1104
+ );
1105
+ }
1106
+ // Effective paid per unit: line total + tax − discount, split evenly.
1107
+ const lineValue = lineItem.totalPrice + lineItem.taxAmount - lineItem.discountAmount;
1108
+ const amount = Math.round((lineValue * line.quantity) / lineItem.quantity);
1109
+ refundLines.push({ lineItemId: lineItem.id, quantity: line.quantity, amount });
1110
+ }
1111
+ const totalAmount = refundLines.reduce((sum, l) => sum + l.amount, 0);
1112
+
1113
+ const performedBy = actor?.userId ?? "system";
1114
+ const policies = await this.refundPolicies(orgId, ctx);
1115
+ if (policies.cap != null) {
1116
+ const usedToday = await this.repo.sumRefundsByOperatorToday(
1117
+ orgId,
1118
+ performedBy,
1119
+ policies.timezone,
1120
+ ctx,
1121
+ );
1122
+ if (usedToday + totalAmount > policies.cap) {
1123
+ return Err(
1124
+ new CommerceForbiddenError(
1125
+ `Daily refund cap exceeded: cap ${policies.cap}, used ${usedToday} today, requested ${totalAmount}.`,
1126
+ ),
1127
+ );
1128
+ }
1129
+ }
1130
+
1131
+ // Move money if a captured payment exists — clamped to what remains.
1132
+ if (found.order.paymentIntentId && (found.order.amountCaptured ?? 0) > 0) {
1133
+ const payments = this.deps.services.payments as
1134
+ | { refund(paymentId: string, amount: number, reason?: string): Promise<unknown> }
1135
+ | undefined;
1136
+ if (payments?.refund) {
1137
+ const priorRefunds = await this.repo.findRefundsByOrderId(orderId, ctx);
1138
+ // Gross total incl. UNDONE refunds: undoRefund reverses the local ledger
1139
+ // but not the payment gateway, so that money already left. Counting only
1140
+ // "completed" would let refund → undo → refund re-issue a gateway refund
1141
+ // (F-04 / R-05 / R-07). Capping on gross keeps total payout ≤ captured.
1142
+ const alreadyRefunded = priorRefunds.reduce((sum, r) => sum + r.amount, 0);
1143
+ const refundable = Math.max(0, (found.order.amountCaptured ?? 0) - alreadyRefunded);
1144
+ const payAmount = Math.min(totalAmount, refundable);
1145
+ if (payAmount > 0) {
1146
+ await payments.refund(
1147
+ found.order.paymentIntentId,
1148
+ payAmount,
1149
+ input.reason ?? "line_refund",
1150
+ );
1151
+ }
1152
+ }
1153
+ }
1154
+
1155
+ for (const line of refundLines) {
1156
+ const lineItem = byId.get(line.lineItemId)!;
1157
+ await this.repo.updateLineItem(
1158
+ line.lineItemId,
1159
+ { refundedQuantity: lineItem.refundedQuantity + line.quantity },
1160
+ ctx,
1161
+ );
1162
+ }
1163
+ const refund = await this.repo.createRefund(
1164
+ {
1165
+ organizationId: orgId,
1166
+ orderId,
1167
+ amount: totalAmount,
1168
+ reason: input.reason ?? null,
1169
+ lines: refundLines,
1170
+ performedBy,
1171
+ },
1172
+ ctx,
1173
+ );
1174
+ await this.repo.createStatusHistory(
1175
+ {
1176
+ orderId,
1177
+ fromStatus: found.order.status,
1178
+ toStatus: found.order.status,
1179
+ reason: `refund ${refund.id}: ${totalAmount} (${input.reason ?? "line refund"})`,
1180
+ changedBy: performedBy,
1181
+ },
1182
+ ctx,
1183
+ );
1184
+
1185
+ const hydrated = await this.hydrateOrder(
1186
+ (await this.repo.findById(orgId, orderId, ctx))!,
1187
+ ctx,
1188
+ );
1189
+ return Ok({ order: hydrated, refund });
1190
+ }
1191
+
1192
+ /**
1193
+ * Undoes a refund within the configured window
1194
+ * (`policies.refundUndoWindowMinutes`, default 15). Restores line
1195
+ * refundedQuantity and marks the ledger row `undone` — an audited,
1196
+ * compensating ledger operation; re-collecting the money (cash back into
1197
+ * the drawer) is the operator's side of the exchange.
1198
+ */
1199
+ async undoRefund(
1200
+ orderId: string,
1201
+ refundId: string,
1202
+ actor: Actor | null,
1203
+ ctx?: TxContext,
1204
+ ): Promise<Result<{ order: HydratedOrder; refund: OrderRefund }>> {
1205
+ try {
1206
+ assertPermission(actor, "orders:update");
1207
+ } catch (error) {
1208
+ return Err(toCommerceError(error));
1209
+ }
1210
+ const orgId = resolveOrgId(actor);
1211
+ const refund = await this.repo.findRefundById(orgId, refundId, ctx);
1212
+ if (!refund || refund.orderId !== orderId) {
1213
+ return Err(new CommerceNotFoundError("Refund not found."));
1214
+ }
1215
+ if (refund.status !== "completed") {
1216
+ return Err(new CommerceValidationError("Refund has already been undone."));
1217
+ }
1218
+ const policies = await this.refundPolicies(orgId, ctx);
1219
+ const ageMs = Date.now() - refund.createdAt.getTime();
1220
+ if (ageMs > policies.undoWindowMinutes * 60_000) {
1221
+ return Err(
1222
+ new CommerceValidationError(
1223
+ `Refund undo window (${policies.undoWindowMinutes} minutes) has passed.`,
1224
+ ),
1225
+ );
1226
+ }
1227
+
1228
+ const performedBy = actor?.userId ?? "system";
1229
+ const undone = await this.repo.markRefundUndone(refundId, performedBy, ctx);
1230
+ if (!undone) {
1231
+ return Err(new CommerceValidationError("Refund has already been undone."));
1232
+ }
1233
+ for (const line of refund.lines) {
1234
+ const lineItem = await this.repo.findLineItemById(line.lineItemId, ctx);
1235
+ if (lineItem) {
1236
+ await this.repo.updateLineItem(
1237
+ line.lineItemId,
1238
+ { refundedQuantity: Math.max(0, lineItem.refundedQuantity - line.quantity) },
1239
+ ctx,
1240
+ );
1241
+ }
1242
+ }
1243
+ await this.repo.createStatusHistory(
1244
+ {
1245
+ orderId,
1246
+ fromStatus: "refund_completed",
1247
+ toStatus: "refund_undone",
1248
+ reason: `refund ${refundId} undone (${refund.amount})`,
1249
+ changedBy: performedBy,
1250
+ },
1251
+ ctx,
1252
+ );
1253
+
1254
+ const order = await this.repo.findById(orgId, orderId, ctx);
1255
+ const hydrated = await this.hydrateOrder(order!, ctx);
1256
+ return Ok({ order: hydrated, refund: undone });
1257
+ }
1258
+
1259
+ async listRefunds(
1260
+ orderId: string,
1261
+ actor: Actor | null,
1262
+ ctx?: TxContext,
1263
+ ): Promise<Result<OrderRefund[]>> {
1264
+ try {
1265
+ assertPermission(actor, "orders:read");
1266
+ } catch (error) {
1267
+ return Err(toCommerceError(error));
1268
+ }
1269
+ const orgId = resolveOrgId(actor);
1270
+ const order = await this.repo.findById(orgId, orderId, ctx);
1271
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
1272
+ return Ok(await this.repo.findRefundsByOrderId(orderId, ctx));
1273
+ }
1274
+
1275
+ /** The acting operator's daily refund-cap status (issue #52). */
1276
+ async refundCapStatus(
1277
+ actor: Actor | null,
1278
+ ctx?: TxContext,
1279
+ ): Promise<Result<{ cap: number | null; usedToday: number; remaining: number | null }>> {
1280
+ try {
1281
+ assertPermission(actor, "orders:read");
1282
+ } catch (error) {
1283
+ return Err(toCommerceError(error));
1284
+ }
1285
+ const orgId = resolveOrgId(actor);
1286
+ const policies = await this.refundPolicies(orgId, ctx);
1287
+ const usedToday = await this.repo.sumRefundsByOperatorToday(
1288
+ orgId,
1289
+ actor?.userId ?? "system",
1290
+ policies.timezone,
1291
+ ctx,
1292
+ );
1293
+ return Ok({
1294
+ cap: policies.cap,
1295
+ usedToday,
1296
+ remaining: policies.cap != null ? Math.max(0, policies.cap - usedToday) : null,
1297
+ });
1298
+ }
1299
+
1300
+ // ── Order notes + activity timeline (issue #56) ─────────────────────────
1301
+
1302
+ private async requireOrderAccess(
1303
+ orderId: string,
1304
+ actor: Actor | null,
1305
+ perm: "orders:read" | "orders:update",
1306
+ ctx?: TxContext,
1307
+ ): Promise<Result<Order>> {
1308
+ try {
1309
+ assertPermission(actor, perm);
1310
+ } catch (error) {
1311
+ return Err(toCommerceError(error));
1312
+ }
1313
+ const orgId = resolveOrgId(actor);
1314
+ const order = await this.repo.findById(orgId, orderId, ctx);
1315
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
1316
+ return Ok(order);
1317
+ }
1318
+
1319
+ async addNote(
1320
+ orderId: string,
1321
+ input: { body: string; pinned?: boolean | undefined },
1322
+ actor: Actor | null,
1323
+ ctx?: TxContext,
1324
+ ): Promise<Result<OrderNote>> {
1325
+ const order = await this.requireOrderAccess(orderId, actor, "orders:update", ctx);
1326
+ if (!order.ok) return order;
1327
+ if (!input.body.trim()) {
1328
+ return Err(new CommerceValidationError("Note body must not be empty."));
1329
+ }
1330
+ const note = await this.repo.createNote(
1331
+ {
1332
+ organizationId: order.value.organizationId,
1333
+ orderId,
1334
+ author: actor?.userId ?? "system",
1335
+ body: input.body,
1336
+ pinned: input.pinned ?? false,
1337
+ },
1338
+ ctx,
1339
+ );
1340
+ return Ok(note);
1341
+ }
1342
+
1343
+ async listNotes(
1344
+ orderId: string,
1345
+ actor: Actor | null,
1346
+ ctx?: TxContext,
1347
+ ): Promise<Result<OrderNote[]>> {
1348
+ const order = await this.requireOrderAccess(orderId, actor, "orders:read", ctx);
1349
+ if (!order.ok) return order;
1350
+ return Ok(await this.repo.findNotesByOrderId(orderId, ctx));
1351
+ }
1352
+
1353
+ async deleteNote(
1354
+ orderId: string,
1355
+ noteId: string,
1356
+ actor: Actor | null,
1357
+ ctx?: TxContext,
1358
+ ): Promise<Result<{ deleted: true }>> {
1359
+ const order = await this.requireOrderAccess(orderId, actor, "orders:update", ctx);
1360
+ if (!order.ok) return order;
1361
+ const deleted = await this.repo.deleteNote(order.value.organizationId, orderId, noteId, ctx);
1362
+ if (!deleted) return Err(new CommerceNotFoundError("Note not found."));
1363
+ return Ok({ deleted: true });
1364
+ }
1365
+
1366
+ /**
1367
+ * One merged per-order activity view (issue #56): status history + operator
1368
+ * notes + refund ledger events (both directions), newest first.
1369
+ */
1370
+ async timeline(
1371
+ orderId: string,
1372
+ actor: Actor | null,
1373
+ ctx?: TxContext,
1374
+ ): Promise<Result<Array<{
1375
+ type: "status" | "note" | "refund";
1376
+ at: Date;
1377
+ actor: string;
1378
+ summary: string;
1379
+ data: Record<string, unknown>;
1380
+ }>>> {
1381
+ const order = await this.requireOrderAccess(orderId, actor, "orders:read", ctx);
1382
+ if (!order.ok) return order;
1383
+
1384
+ const [history, notes, refunds] = await Promise.all([
1385
+ this.repo.findStatusHistoryByOrderId(orderId, ctx),
1386
+ this.repo.findNotesByOrderId(orderId, ctx),
1387
+ this.repo.findRefundsByOrderId(orderId, ctx),
1388
+ ]);
1389
+
1390
+ const events: Array<{
1391
+ type: "status" | "note" | "refund";
1392
+ at: Date;
1393
+ actor: string;
1394
+ summary: string;
1395
+ data: Record<string, unknown>;
1396
+ }> = [];
1397
+
1398
+ for (const entry of history) {
1399
+ events.push({
1400
+ type: "status",
1401
+ at: entry.changedAt,
1402
+ actor: entry.changedBy,
1403
+ summary: `Status ${entry.fromStatus} → ${entry.toStatus}${entry.reason ? ` (${entry.reason})` : ""}`,
1404
+ data: { fromStatus: entry.fromStatus, toStatus: entry.toStatus, reason: entry.reason },
1405
+ });
1406
+ }
1407
+ for (const note of notes) {
1408
+ events.push({
1409
+ type: "note",
1410
+ at: note.createdAt,
1411
+ actor: note.author,
1412
+ summary: note.body,
1413
+ data: { noteId: note.id, pinned: note.pinned },
1414
+ });
1415
+ }
1416
+ for (const refund of refunds) {
1417
+ events.push({
1418
+ type: "refund",
1419
+ at: refund.createdAt,
1420
+ actor: refund.performedBy,
1421
+ summary: `Refund of ${refund.amount}${refund.reason ? ` (${refund.reason})` : ""}`,
1422
+ data: { refundId: refund.id, amount: refund.amount, lines: refund.lines, status: refund.status },
1423
+ });
1424
+ if (refund.status === "undone" && refund.undoneAt) {
1425
+ events.push({
1426
+ type: "refund",
1427
+ at: refund.undoneAt,
1428
+ actor: refund.undoneBy ?? "system",
1429
+ summary: `Refund of ${refund.amount} undone`,
1430
+ data: { refundId: refund.id, amount: refund.amount, undone: true },
1431
+ });
1432
+ }
1433
+ }
1434
+
1435
+ events.sort((a, b) => b.at.getTime() - a.at.getTime());
1436
+ return Ok(events);
1437
+ }
1438
+
701
1439
  /**
702
1440
  * Capture an authorized payment for an order via the payment adapter and
703
1441
  * record the captured amount. Does not transition order status — capture is a
@@ -784,6 +1522,244 @@ export class OrderService {
784
1522
  return Ok(items);
785
1523
  }
786
1524
 
1525
+ /** Line items may not be edited on terminal or fully-captured orders. */
1526
+ private lineItemEditGuard(order: Order): CommerceValidationError | null {
1527
+ if (this.machine.terminal.includes(order.status)) {
1528
+ return new CommerceValidationError(
1529
+ `Order in terminal status "${order.status}" cannot be edited.`,
1530
+ );
1531
+ }
1532
+ if (
1533
+ order.amountCaptured != null &&
1534
+ order.amountCaptured >= order.grandTotal
1535
+ ) {
1536
+ return new CommerceValidationError(
1537
+ "Order payment is fully captured; line items cannot be edited.",
1538
+ );
1539
+ }
1540
+ // R-06: a checkout order carries a paymentIntentId. If amountCaptured is null
1541
+ // (auth-only capture, or a mock adapter reporting 0), treat it as authorized —
1542
+ // an order with a live payment must not have its lines silently edited/inflated.
1543
+ if (order.paymentIntentId != null && order.amountCaptured == null) {
1544
+ return new CommerceValidationError(
1545
+ "Order has an authorized payment; line items cannot be edited.",
1546
+ );
1547
+ }
1548
+ return null;
1549
+ }
1550
+
1551
+ /**
1552
+ * Recomputes subtotal/taxTotal/grandTotal from the order's line items
1553
+ * (shippingTotal and order-level discountTotal are preserved), persists
1554
+ * them, and records an audit entry in the status history.
1555
+ */
1556
+ private async recalcOrderTotals(
1557
+ order: Order,
1558
+ actor: Actor | null,
1559
+ reason: string,
1560
+ ctx?: TxContext,
1561
+ ): Promise<HydratedOrder> {
1562
+ const lineItems = await this.repo.findLineItemsByOrderId(order.id, ctx);
1563
+ const subtotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
1564
+ const taxTotal = lineItems.reduce((sum, li) => sum + li.taxAmount, 0);
1565
+ const grandTotal =
1566
+ subtotal + taxTotal + order.shippingTotal - order.discountTotal;
1567
+ const updated = await this.repo.update(
1568
+ order.id,
1569
+ { subtotal, taxTotal, grandTotal },
1570
+ ctx,
1571
+ );
1572
+ await this.repo.createStatusHistory(
1573
+ {
1574
+ orderId: order.id,
1575
+ fromStatus: order.status,
1576
+ toStatus: order.status,
1577
+ reason,
1578
+ changedBy: actor?.userId ?? "system",
1579
+ },
1580
+ ctx,
1581
+ );
1582
+ return { ...(updated ?? order), lineItems };
1583
+ }
1584
+
1585
+ async addLineItem(
1586
+ orderId: string,
1587
+ input: {
1588
+ entityId: string;
1589
+ entityType: string;
1590
+ variantId?: string | undefined;
1591
+ sku?: string | undefined;
1592
+ title: string;
1593
+ quantity: number;
1594
+ unitPrice: number;
1595
+ totalPrice?: number | undefined;
1596
+ taxAmount?: number | undefined;
1597
+ discountAmount?: number | undefined;
1598
+ metadata?: Record<string, unknown> | undefined;
1599
+ },
1600
+ actor: Actor | null,
1601
+ ctx?: TxContext,
1602
+ ): Promise<Result<HydratedOrder>> {
1603
+ try {
1604
+ assertPermission(actor, "orders:update");
1605
+ } catch (error) {
1606
+ return Err(toCommerceError(error));
1607
+ }
1608
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
1609
+ const order = await this.repo.findById(orgId, orderId, ctx);
1610
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
1611
+ const guard = this.lineItemEditGuard(order);
1612
+ if (guard) return Err(guard);
1613
+
1614
+ // Org-integrity + price provenance for the added line (same rules as create):
1615
+ // the entity must belong to the order's org, and the price is server-derived
1616
+ // unless the actor holds `orders:manage` (then honored + flagged isCustomPrice).
1617
+ const resolvedResult = await this.resolveLines(
1618
+ {
1619
+ currency: order.currency,
1620
+ subtotal: 0,
1621
+ taxTotal: 0,
1622
+ shippingTotal: 0,
1623
+ grandTotal: 0,
1624
+ lineItems: [
1625
+ {
1626
+ entityId: input.entityId,
1627
+ entityType: input.entityType,
1628
+ title: input.title,
1629
+ quantity: input.quantity,
1630
+ unitPrice: input.unitPrice,
1631
+ totalPrice: input.totalPrice ?? input.unitPrice * input.quantity,
1632
+ ...(input.variantId !== undefined ? { variantId: input.variantId } : {}),
1633
+ ...(input.sku !== undefined ? { sku: input.sku } : {}),
1634
+ ...(input.taxAmount !== undefined ? { taxAmount: input.taxAmount } : {}),
1635
+ ...(input.discountAmount !== undefined
1636
+ ? { discountAmount: input.discountAmount }
1637
+ : {}),
1638
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
1639
+ },
1640
+ ],
1641
+ ...(order.customerId ? { customerId: order.customerId } : {}),
1642
+ },
1643
+ actor,
1644
+ false,
1645
+ ctx,
1646
+ );
1647
+ if (!resolvedResult.ok) return Err(resolvedResult.error);
1648
+ const line = resolvedResult.value[0]!;
1649
+
1650
+ await this.repo.createLineItems(
1651
+ [
1652
+ {
1653
+ orderId: order.id,
1654
+ entityId: line.entityId,
1655
+ entityType: line.entityType,
1656
+ title: line.title,
1657
+ quantity: line.quantity,
1658
+ unitPrice: line.unitPrice,
1659
+ totalPrice: line.totalPrice ?? line.unitPrice * line.quantity,
1660
+ isCustomPrice: line.isCustomPrice,
1661
+ taxAmount: line.taxAmount ?? 0,
1662
+ discountAmount: line.discountAmount ?? 0,
1663
+ fulfillmentStatus: "unfulfilled",
1664
+ metadata: line.metadata ?? {},
1665
+ ...(line.variantId !== undefined ? { variantId: line.variantId } : {}),
1666
+ ...(line.sku !== undefined ? { sku: line.sku } : {}),
1667
+ },
1668
+ ],
1669
+ ctx,
1670
+ );
1671
+
1672
+ return Ok(await this.recalcOrderTotals(order, actor, "line_item_added", ctx));
1673
+ }
1674
+
1675
+ async updateOrderLineItem(
1676
+ orderId: string,
1677
+ lineItemId: string,
1678
+ patch: { quantity: number },
1679
+ actor: Actor | null,
1680
+ ctx?: TxContext,
1681
+ ): Promise<Result<HydratedOrder>> {
1682
+ try {
1683
+ assertPermission(actor, "orders:update");
1684
+ } catch (error) {
1685
+ return Err(toCommerceError(error));
1686
+ }
1687
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
1688
+ const order = await this.repo.findById(orgId, orderId, ctx);
1689
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
1690
+ const guard = this.lineItemEditGuard(order);
1691
+ if (guard) return Err(guard);
1692
+
1693
+ const line = await this.repo.findLineItemById(lineItemId, ctx);
1694
+ if (!line || line.orderId !== order.id) {
1695
+ return Err(new CommerceNotFoundError("Line item not found on this order."));
1696
+ }
1697
+ if (line.fulfillmentStatus !== "unfulfilled") {
1698
+ return Err(
1699
+ new CommerceValidationError(
1700
+ "Line items with fulfillment progress cannot be adjusted.",
1701
+ ),
1702
+ );
1703
+ }
1704
+ if (patch.quantity < 1) {
1705
+ return Err(new CommerceValidationError("Quantity must be at least 1."));
1706
+ }
1707
+
1708
+ // Scale line totals with the quantity change (tax scales per-unit).
1709
+ const perUnitTax = line.quantity > 0 ? line.taxAmount / line.quantity : 0;
1710
+ await this.repo.updateLineItem(
1711
+ lineItemId,
1712
+ {
1713
+ quantity: patch.quantity,
1714
+ totalPrice: line.unitPrice * patch.quantity,
1715
+ taxAmount: Math.round(perUnitTax * patch.quantity),
1716
+ },
1717
+ ctx,
1718
+ );
1719
+
1720
+ return Ok(await this.recalcOrderTotals(order, actor, "line_item_updated", ctx));
1721
+ }
1722
+
1723
+ async removeLineItem(
1724
+ orderId: string,
1725
+ lineItemId: string,
1726
+ actor: Actor | null,
1727
+ ctx?: TxContext,
1728
+ ): Promise<Result<HydratedOrder>> {
1729
+ try {
1730
+ assertPermission(actor, "orders:update");
1731
+ } catch (error) {
1732
+ return Err(toCommerceError(error));
1733
+ }
1734
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
1735
+ const order = await this.repo.findById(orgId, orderId, ctx);
1736
+ if (!order) return Err(new CommerceNotFoundError("Order not found."));
1737
+ const guard = this.lineItemEditGuard(order);
1738
+ if (guard) return Err(guard);
1739
+
1740
+ const line = await this.repo.findLineItemById(lineItemId, ctx);
1741
+ if (!line || line.orderId !== order.id) {
1742
+ return Err(new CommerceNotFoundError("Line item not found on this order."));
1743
+ }
1744
+ if (line.fulfillmentStatus !== "unfulfilled") {
1745
+ return Err(
1746
+ new CommerceValidationError(
1747
+ "Line items with fulfillment progress cannot be removed.",
1748
+ ),
1749
+ );
1750
+ }
1751
+ const existing = await this.repo.findLineItemsByOrderId(order.id, ctx);
1752
+ if (existing.length <= 1) {
1753
+ return Err(
1754
+ new CommerceValidationError("An order must keep at least one line item."),
1755
+ );
1756
+ }
1757
+
1758
+ await this.repo.deleteLineItem(lineItemId, ctx);
1759
+
1760
+ return Ok(await this.recalcOrderTotals(order, actor, "line_item_removed", ctx));
1761
+ }
1762
+
787
1763
  async updateOrder(
788
1764
  orderId: string,
789
1765
  data: {