@dainamite/cpq 0.2.2

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 (504) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +236 -0
  4. package/README.md +108 -0
  5. package/dist/modules/cpq/acl.js +30 -0
  6. package/dist/modules/cpq/acl.js.map +7 -0
  7. package/dist/modules/cpq/api/_helpers/handleArcError.js +37 -0
  8. package/dist/modules/cpq/api/_helpers/handleArcError.js.map +7 -0
  9. package/dist/modules/cpq/api/attribute-options/route.js +47 -0
  10. package/dist/modules/cpq/api/attribute-options/route.js.map +7 -0
  11. package/dist/modules/cpq/api/bundle-slots/route.js +101 -0
  12. package/dist/modules/cpq/api/bundle-slots/route.js.map +7 -0
  13. package/dist/modules/cpq/api/bundle-tree/route.js +28 -0
  14. package/dist/modules/cpq/api/bundle-tree/route.js.map +7 -0
  15. package/dist/modules/cpq/api/context.js +29 -0
  16. package/dist/modules/cpq/api/context.js.map +7 -0
  17. package/dist/modules/cpq/api/inventory/assets/route.js +113 -0
  18. package/dist/modules/cpq/api/inventory/assets/route.js.map +7 -0
  19. package/dist/modules/cpq/api/inventory/assets/status/route.js +35 -0
  20. package/dist/modules/cpq/api/inventory/assets/status/route.js.map +7 -0
  21. package/dist/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.js +30 -0
  22. package/dist/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.js.map +7 -0
  23. package/dist/modules/cpq/api/inventory/customer/[customerId]/route.js +31 -0
  24. package/dist/modules/cpq/api/inventory/customer/[customerId]/route.js.map +7 -0
  25. package/dist/modules/cpq/api/inventory/subscription-items/route.js +99 -0
  26. package/dist/modules/cpq/api/inventory/subscription-items/route.js.map +7 -0
  27. package/dist/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.js +57 -0
  28. package/dist/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.js.map +7 -0
  29. package/dist/modules/cpq/api/inventory/subscriptions/expiring/route.js +62 -0
  30. package/dist/modules/cpq/api/inventory/subscriptions/expiring/route.js.map +7 -0
  31. package/dist/modules/cpq/api/inventory/subscriptions/route.js +116 -0
  32. package/dist/modules/cpq/api/inventory/subscriptions/route.js.map +7 -0
  33. package/dist/modules/cpq/api/inventory/subscriptions/status/route.js +35 -0
  34. package/dist/modules/cpq/api/inventory/subscriptions/status/route.js.map +7 -0
  35. package/dist/modules/cpq/api/offering-components/route.js +91 -0
  36. package/dist/modules/cpq/api/offering-components/route.js.map +7 -0
  37. package/dist/modules/cpq/api/orders/[orderId]/activate/route.js +39 -0
  38. package/dist/modules/cpq/api/orders/[orderId]/activate/route.js.map +7 -0
  39. package/dist/modules/cpq/api/orders/[orderId]/route.js +28 -0
  40. package/dist/modules/cpq/api/orders/[orderId]/route.js.map +7 -0
  41. package/dist/modules/cpq/api/orders/[orderId]/status/route.js +38 -0
  42. package/dist/modules/cpq/api/orders/[orderId]/status/route.js.map +7 -0
  43. package/dist/modules/cpq/api/orders/route.js +71 -0
  44. package/dist/modules/cpq/api/orders/route.js.map +7 -0
  45. package/dist/modules/cpq/api/price-rules/route.js +180 -0
  46. package/dist/modules/cpq/api/price-rules/route.js.map +7 -0
  47. package/dist/modules/cpq/api/pricing-table-entries/route.js +142 -0
  48. package/dist/modules/cpq/api/pricing-table-entries/route.js.map +7 -0
  49. package/dist/modules/cpq/api/pricing-tables/route.js +179 -0
  50. package/dist/modules/cpq/api/pricing-tables/route.js.map +7 -0
  51. package/dist/modules/cpq/api/product-attributes/route.js +160 -0
  52. package/dist/modules/cpq/api/product-attributes/route.js.map +7 -0
  53. package/dist/modules/cpq/api/product-charges/route.js +166 -0
  54. package/dist/modules/cpq/api/product-charges/route.js.map +7 -0
  55. package/dist/modules/cpq/api/product-offerings/route.js +294 -0
  56. package/dist/modules/cpq/api/product-offerings/route.js.map +7 -0
  57. package/dist/modules/cpq/api/product-relationships/route.js +144 -0
  58. package/dist/modules/cpq/api/product-relationships/route.js.map +7 -0
  59. package/dist/modules/cpq/api/product-specifications/route.js +292 -0
  60. package/dist/modules/cpq/api/product-specifications/route.js.map +7 -0
  61. package/dist/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.js +71 -0
  62. package/dist/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.js.map +7 -0
  63. package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js +29 -0
  64. package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js.map +7 -0
  65. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +54 -0
  66. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +7 -0
  67. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -0
  68. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +7 -0
  69. package/dist/modules/cpq/api/quotes/[quoteId]/merge-meta/route.js +72 -0
  70. package/dist/modules/cpq/api/quotes/[quoteId]/merge-meta/route.js.map +7 -0
  71. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +35 -0
  72. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +7 -0
  73. package/dist/modules/cpq/api/quotes/[quoteId]/route.js +102 -0
  74. package/dist/modules/cpq/api/quotes/[quoteId]/route.js.map +7 -0
  75. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +27 -0
  76. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +7 -0
  77. package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.js +79 -0
  78. package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.js.map +7 -0
  79. package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.js +95 -0
  80. package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.js.map +7 -0
  81. package/dist/modules/cpq/api/quotes/attributes/route.js +52 -0
  82. package/dist/modules/cpq/api/quotes/attributes/route.js.map +7 -0
  83. package/dist/modules/cpq/api/quotes/from-subscription/route.js +72 -0
  84. package/dist/modules/cpq/api/quotes/from-subscription/route.js.map +7 -0
  85. package/dist/modules/cpq/api/quotes/price/route.js +69 -0
  86. package/dist/modules/cpq/api/quotes/price/route.js.map +7 -0
  87. package/dist/modules/cpq/api/quotes/route.js +97 -0
  88. package/dist/modules/cpq/api/quotes/route.js.map +7 -0
  89. package/dist/modules/cpq/api/quotes/validate-relationships/route.js +86 -0
  90. package/dist/modules/cpq/api/quotes/validate-relationships/route.js.map +7 -0
  91. package/dist/modules/cpq/api/resolveQuotingService.js +7 -0
  92. package/dist/modules/cpq/api/resolveQuotingService.js.map +7 -0
  93. package/dist/modules/cpq/api/wizards/resolve/route.js +38 -0
  94. package/dist/modules/cpq/api/wizards/resolve/route.js.map +7 -0
  95. package/dist/modules/cpq/api/wizards/route.js +111 -0
  96. package/dist/modules/cpq/api/wizards/route.js.map +7 -0
  97. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js +136 -0
  98. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +7 -0
  99. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js +185 -0
  100. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +7 -0
  101. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js +343 -0
  102. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js.map +7 -0
  103. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js +13 -0
  104. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js.map +7 -0
  105. package/dist/modules/cpq/backend/cpq/inventory/page.js +35 -0
  106. package/dist/modules/cpq/backend/cpq/inventory/page.js.map +7 -0
  107. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +18 -0
  108. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +7 -0
  109. package/dist/modules/cpq/backend/cpq/inventory/shared.js +20 -0
  110. package/dist/modules/cpq/backend/cpq/inventory/shared.js.map +7 -0
  111. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +794 -0
  112. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +7 -0
  113. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js +13 -0
  114. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js.map +7 -0
  115. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +917 -0
  116. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +7 -0
  117. package/dist/modules/cpq/backend/cpq/offerings/page.js +405 -0
  118. package/dist/modules/cpq/backend/cpq/offerings/page.js.map +7 -0
  119. package/dist/modules/cpq/backend/cpq/offerings/page.meta.js +18 -0
  120. package/dist/modules/cpq/backend/cpq/offerings/page.meta.js.map +7 -0
  121. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +677 -0
  122. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +7 -0
  123. package/dist/modules/cpq/backend/cpq/orders/page.js +134 -0
  124. package/dist/modules/cpq/backend/cpq/orders/page.js.map +7 -0
  125. package/dist/modules/cpq/backend/cpq/orders/page.meta.js +18 -0
  126. package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +7 -0
  127. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js +272 -0
  128. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js.map +7 -0
  129. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js +407 -0
  130. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js.map +7 -0
  131. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js +13 -0
  132. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js.map +7 -0
  133. package/dist/modules/cpq/backend/cpq/price-rules/page.js +211 -0
  134. package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +7 -0
  135. package/dist/modules/cpq/backend/cpq/price-rules/page.meta.js +18 -0
  136. package/dist/modules/cpq/backend/cpq/price-rules/page.meta.js.map +7 -0
  137. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +275 -0
  138. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +7 -0
  139. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js +112 -0
  140. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js.map +7 -0
  141. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js +492 -0
  142. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js.map +7 -0
  143. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.js +50 -0
  144. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.js.map +7 -0
  145. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js +249 -0
  146. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js.map +7 -0
  147. package/dist/modules/cpq/backend/cpq/pricing/page.js +87 -0
  148. package/dist/modules/cpq/backend/cpq/pricing/page.js.map +7 -0
  149. package/dist/modules/cpq/backend/cpq/pricing/page.meta.js +18 -0
  150. package/dist/modules/cpq/backend/cpq/pricing/page.meta.js.map +7 -0
  151. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js +652 -0
  152. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js.map +7 -0
  153. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +1289 -0
  154. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +7 -0
  155. package/dist/modules/cpq/backend/cpq/quotes/page.js +283 -0
  156. package/dist/modules/cpq/backend/cpq/quotes/page.js.map +7 -0
  157. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +18 -0
  158. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +7 -0
  159. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js +214 -0
  160. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js.map +7 -0
  161. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +1399 -0
  162. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +7 -0
  163. package/dist/modules/cpq/backend/cpq/specifications/page.js +148 -0
  164. package/dist/modules/cpq/backend/cpq/specifications/page.js.map +7 -0
  165. package/dist/modules/cpq/backend/cpq/specifications/page.meta.js +18 -0
  166. package/dist/modules/cpq/backend/cpq/specifications/page.meta.js.map +7 -0
  167. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js +204 -0
  168. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js.map +7 -0
  169. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js +191 -0
  170. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js.map +7 -0
  171. package/dist/modules/cpq/backend/cpq/wizards/page.js +119 -0
  172. package/dist/modules/cpq/backend/cpq/wizards/page.js.map +7 -0
  173. package/dist/modules/cpq/backend/cpq/wizards/page.meta.js +18 -0
  174. package/dist/modules/cpq/backend/cpq/wizards/page.meta.js.map +7 -0
  175. package/dist/modules/cpq/ce.js +151 -0
  176. package/dist/modules/cpq/ce.js.map +7 -0
  177. package/dist/modules/cpq/cli.js +11 -0
  178. package/dist/modules/cpq/cli.js.map +7 -0
  179. package/dist/modules/cpq/components/CpqListView.js +265 -0
  180. package/dist/modules/cpq/components/CpqListView.js.map +7 -0
  181. package/dist/modules/cpq/components/NumberInput.js +95 -0
  182. package/dist/modules/cpq/components/NumberInput.js.map +7 -0
  183. package/dist/modules/cpq/components/StatusPath.js +60 -0
  184. package/dist/modules/cpq/components/StatusPath.js.map +7 -0
  185. package/dist/modules/cpq/components/statusMaps.js +66 -0
  186. package/dist/modules/cpq/components/statusMaps.js.map +7 -0
  187. package/dist/modules/cpq/components/useCpqRowActions.js +125 -0
  188. package/dist/modules/cpq/components/useCpqRowActions.js.map +7 -0
  189. package/dist/modules/cpq/data/entities.js +1556 -0
  190. package/dist/modules/cpq/data/entities.js.map +7 -0
  191. package/dist/modules/cpq/data/validators.js +575 -0
  192. package/dist/modules/cpq/data/validators.js.map +7 -0
  193. package/dist/modules/cpq/di.js +57 -0
  194. package/dist/modules/cpq/di.js.map +7 -0
  195. package/dist/modules/cpq/events.js +50 -0
  196. package/dist/modules/cpq/events.js.map +7 -0
  197. package/dist/modules/cpq/index.js +16 -0
  198. package/dist/modules/cpq/index.js.map +7 -0
  199. package/dist/modules/cpq/lib/seeds/api.js +32 -0
  200. package/dist/modules/cpq/lib/seeds/api.js.map +7 -0
  201. package/dist/modules/cpq/lib/seeds/tenant-provisioning.js +158 -0
  202. package/dist/modules/cpq/lib/seeds/tenant-provisioning.js.map +7 -0
  203. package/dist/modules/cpq/lib/seeds.js +6 -0
  204. package/dist/modules/cpq/lib/seeds.js.map +7 -0
  205. package/dist/modules/cpq/migrations/Migration20260220220000_cpq.js +40 -0
  206. package/dist/modules/cpq/migrations/Migration20260220220000_cpq.js.map +7 -0
  207. package/dist/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.js +54 -0
  208. package/dist/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.js.map +7 -0
  209. package/dist/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.js +15 -0
  210. package/dist/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.js.map +7 -0
  211. package/dist/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.js +62 -0
  212. package/dist/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.js.map +7 -0
  213. package/dist/modules/cpq/migrations/Migration20260402000000_cpq_workflows.js +56 -0
  214. package/dist/modules/cpq/migrations/Migration20260402000000_cpq_workflows.js.map +7 -0
  215. package/dist/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.js +26 -0
  216. package/dist/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.js.map +7 -0
  217. package/dist/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.js +37 -0
  218. package/dist/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.js.map +7 -0
  219. package/dist/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.js +35 -0
  220. package/dist/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.js.map +7 -0
  221. package/dist/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.js +21 -0
  222. package/dist/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.js.map +7 -0
  223. package/dist/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.js +46 -0
  224. package/dist/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.js.map +7 -0
  225. package/dist/modules/cpq/migrations/Migration20260410100000_cpq_inventory.js +118 -0
  226. package/dist/modules/cpq/migrations/Migration20260410100000_cpq_inventory.js.map +7 -0
  227. package/dist/modules/cpq/migrations/Migration20260411000000_cpq_orders.js +63 -0
  228. package/dist/modules/cpq/migrations/Migration20260411000000_cpq_orders.js.map +7 -0
  229. package/dist/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.js +28 -0
  230. package/dist/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.js.map +7 -0
  231. package/dist/modules/cpq/migrations/Migration20260504064621_cpq_arc.js +36 -0
  232. package/dist/modules/cpq/migrations/Migration20260504064621_cpq_arc.js.map +7 -0
  233. package/dist/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.js +14 -0
  234. package/dist/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.js.map +7 -0
  235. package/dist/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.js +14 -0
  236. package/dist/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.js.map +7 -0
  237. package/dist/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.js +58 -0
  238. package/dist/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.js.map +7 -0
  239. package/dist/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.js +29 -0
  240. package/dist/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.js.map +7 -0
  241. package/dist/modules/cpq/services/cpqBundleService.js +447 -0
  242. package/dist/modules/cpq/services/cpqBundleService.js.map +7 -0
  243. package/dist/modules/cpq/services/cpqInventoryService.js +1371 -0
  244. package/dist/modules/cpq/services/cpqInventoryService.js.map +7 -0
  245. package/dist/modules/cpq/services/cpqOrderService.js +901 -0
  246. package/dist/modules/cpq/services/cpqOrderService.js.map +7 -0
  247. package/dist/modules/cpq/services/cpqPricingService.js +289 -0
  248. package/dist/modules/cpq/services/cpqPricingService.js.map +7 -0
  249. package/dist/modules/cpq/services/cpqProductService.js +204 -0
  250. package/dist/modules/cpq/services/cpqProductService.js.map +7 -0
  251. package/dist/modules/cpq/services/cpqQuotingService.js +1867 -0
  252. package/dist/modules/cpq/services/cpqQuotingService.js.map +7 -0
  253. package/dist/modules/cpq/services/cpqValidationService.js +234 -0
  254. package/dist/modules/cpq/services/cpqValidationService.js.map +7 -0
  255. package/dist/modules/cpq/services/cpqWizardService.js +137 -0
  256. package/dist/modules/cpq/services/cpqWizardService.js.map +7 -0
  257. package/dist/modules/cpq/services/types.js +114 -0
  258. package/dist/modules/cpq/services/types.js.map +7 -0
  259. package/dist/modules/cpq/setup.js +49 -0
  260. package/dist/modules/cpq/setup.js.map +7 -0
  261. package/dist/modules/cpq/subscribers/subscription-amended-log.js +18 -0
  262. package/dist/modules/cpq/subscribers/subscription-amended-log.js.map +7 -0
  263. package/dist/modules/cpq/subscribers/subscription-cancelled-log.js +19 -0
  264. package/dist/modules/cpq/subscribers/subscription-cancelled-log.js.map +7 -0
  265. package/dist/modules/cpq/subscribers/subscription-merged-log.js +20 -0
  266. package/dist/modules/cpq/subscribers/subscription-merged-log.js.map +7 -0
  267. package/dist/modules/cpq/subscribers/subscription-renewed-log.js +18 -0
  268. package/dist/modules/cpq/subscribers/subscription-renewed-log.js.map +7 -0
  269. package/dist/modules/cpq/subscribers/subscription-superseded-log.js +18 -0
  270. package/dist/modules/cpq/subscribers/subscription-superseded-log.js.map +7 -0
  271. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js +192 -0
  272. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js.map +7 -0
  273. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.js +17 -0
  274. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.js.map +7 -0
  275. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js +79 -0
  276. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js.map +7 -0
  277. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.js +17 -0
  278. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.js.map +7 -0
  279. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js +181 -0
  280. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js.map +7 -0
  281. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.js +17 -0
  282. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.js.map +7 -0
  283. package/dist/modules/cpq/widgets/injection-table.js +44 -0
  284. package/dist/modules/cpq/widgets/injection-table.js.map +7 -0
  285. package/dist/modules/cpq/workflows/WizardRunner.js +113 -0
  286. package/dist/modules/cpq/workflows/WizardRunner.js.map +7 -0
  287. package/dist/modules/cpq/workflows/WizardTrigger.js +116 -0
  288. package/dist/modules/cpq/workflows/WizardTrigger.js.map +7 -0
  289. package/dist/modules/cpq/workflows/engine.js +188 -0
  290. package/dist/modules/cpq/workflows/engine.js.map +7 -0
  291. package/dist/modules/cpq/workflows/registry.js +16 -0
  292. package/dist/modules/cpq/workflows/registry.js.map +7 -0
  293. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js +119 -0
  294. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js.map +7 -0
  295. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js +24 -0
  296. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js.map +7 -0
  297. package/dist/modules/cpq/workflows/steps/ItemListStep.js +115 -0
  298. package/dist/modules/cpq/workflows/steps/ItemListStep.js.map +7 -0
  299. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js +327 -0
  300. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js.map +7 -0
  301. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js +231 -0
  302. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js.map +7 -0
  303. package/dist/modules/cpq/workflows/steps/ReviewStep.js +152 -0
  304. package/dist/modules/cpq/workflows/steps/ReviewStep.js.map +7 -0
  305. package/dist/modules/cpq/workflows/steps/index.js +49 -0
  306. package/dist/modules/cpq/workflows/steps/index.js.map +7 -0
  307. package/dist/modules/cpq/workflows/types.js +1 -0
  308. package/dist/modules/cpq/workflows/types.js.map +7 -0
  309. package/package.json +150 -0
  310. package/src/modules/cpq/__integration__/TC-CPQ-001.spec.ts +177 -0
  311. package/src/modules/cpq/__integration__/TC-CPQ-002.spec.ts +123 -0
  312. package/src/modules/cpq/__integration__/TC-CPQ-003.spec.ts +115 -0
  313. package/src/modules/cpq/__integration__/TC-CPQ-004.spec.ts +184 -0
  314. package/src/modules/cpq/__integration__/TC-CPQ-005.spec.ts +43 -0
  315. package/src/modules/cpq/__integration__/TC-CPQ-006.spec.ts +87 -0
  316. package/src/modules/cpq/__integration__/TC-CPQ-007.spec.ts +79 -0
  317. package/src/modules/cpq/__integration__/TC-CPQ-008.spec.ts +55 -0
  318. package/src/modules/cpq/__integration__/TC-CPQ-009.spec.ts +45 -0
  319. package/src/modules/cpq/__integration__/TC-CPQ-010.spec.ts +54 -0
  320. package/src/modules/cpq/__integration__/TC-CPQ-011.spec.ts +24 -0
  321. package/src/modules/cpq/__integration__/TC-CPQ-012.spec.ts +62 -0
  322. package/src/modules/cpq/__integration__/TC-CPQ-ARC-001.spec.ts +52 -0
  323. package/src/modules/cpq/__integration__/TC-CPQ-ARC-002.spec.ts +65 -0
  324. package/src/modules/cpq/__integration__/TC-CPQ-ARC-003.spec.ts +107 -0
  325. package/src/modules/cpq/__integration__/TC-CPQ-ARC-004.spec.ts +74 -0
  326. package/src/modules/cpq/__integration__/TC-CPQ-ARC-005.spec.ts +86 -0
  327. package/src/modules/cpq/__integration__/TC-CPQ-ARC-006.spec.ts +79 -0
  328. package/src/modules/cpq/__integration__/TC-CPQ-ARC-007.spec.ts +63 -0
  329. package/src/modules/cpq/__integration__/TC-CPQ-ARC-008.spec.ts +62 -0
  330. package/src/modules/cpq/__integration__/TC-CPQ-ARC-009.spec.ts +62 -0
  331. package/src/modules/cpq/__integration__/TC-CPQ-ARC-010.spec.ts +59 -0
  332. package/src/modules/cpq/__integration__/TC-CPQ-ARC-011.spec.ts +128 -0
  333. package/src/modules/cpq/__integration__/_helpers/cpqArcFixtures.ts +208 -0
  334. package/src/modules/cpq/__tests__/package-isolation.test.ts +232 -0
  335. package/src/modules/cpq/acl.ts +26 -0
  336. package/src/modules/cpq/api/_helpers/__tests__/handleArcError.test.ts +117 -0
  337. package/src/modules/cpq/api/_helpers/handleArcError.ts +81 -0
  338. package/src/modules/cpq/api/attribute-options/route.ts +55 -0
  339. package/src/modules/cpq/api/bundle-slots/route.ts +112 -0
  340. package/src/modules/cpq/api/bundle-tree/route.ts +31 -0
  341. package/src/modules/cpq/api/context.ts +39 -0
  342. package/src/modules/cpq/api/inventory/assets/route.ts +123 -0
  343. package/src/modules/cpq/api/inventory/assets/status/route.ts +34 -0
  344. package/src/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.ts +30 -0
  345. package/src/modules/cpq/api/inventory/customer/[customerId]/route.ts +31 -0
  346. package/src/modules/cpq/api/inventory/subscription-items/route.ts +107 -0
  347. package/src/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.ts +64 -0
  348. package/src/modules/cpq/api/inventory/subscriptions/expiring/route.ts +66 -0
  349. package/src/modules/cpq/api/inventory/subscriptions/route.ts +126 -0
  350. package/src/modules/cpq/api/inventory/subscriptions/status/route.ts +34 -0
  351. package/src/modules/cpq/api/offering-components/route.ts +103 -0
  352. package/src/modules/cpq/api/orders/[orderId]/activate/route.ts +48 -0
  353. package/src/modules/cpq/api/orders/[orderId]/route.ts +28 -0
  354. package/src/modules/cpq/api/orders/[orderId]/status/route.ts +38 -0
  355. package/src/modules/cpq/api/orders/route.ts +77 -0
  356. package/src/modules/cpq/api/price-rules/route.ts +210 -0
  357. package/src/modules/cpq/api/pricing-table-entries/route.ts +159 -0
  358. package/src/modules/cpq/api/pricing-tables/route.ts +202 -0
  359. package/src/modules/cpq/api/product-attributes/route.ts +175 -0
  360. package/src/modules/cpq/api/product-charges/route.ts +181 -0
  361. package/src/modules/cpq/api/product-offerings/route.ts +329 -0
  362. package/src/modules/cpq/api/product-relationships/route.ts +156 -0
  363. package/src/modules/cpq/api/product-specifications/route.ts +325 -0
  364. package/src/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.ts +77 -0
  365. package/src/modules/cpq/api/quotes/[quoteId]/clone/route.ts +28 -0
  366. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +55 -0
  367. package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +26 -0
  368. package/src/modules/cpq/api/quotes/[quoteId]/merge-meta/route.ts +78 -0
  369. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +34 -0
  370. package/src/modules/cpq/api/quotes/[quoteId]/route.ts +109 -0
  371. package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +26 -0
  372. package/src/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.ts +91 -0
  373. package/src/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.ts +107 -0
  374. package/src/modules/cpq/api/quotes/attributes/route.ts +61 -0
  375. package/src/modules/cpq/api/quotes/from-subscription/route.ts +85 -0
  376. package/src/modules/cpq/api/quotes/price/route.ts +81 -0
  377. package/src/modules/cpq/api/quotes/route.ts +114 -0
  378. package/src/modules/cpq/api/quotes/validate-relationships/route.ts +100 -0
  379. package/src/modules/cpq/api/resolveQuotingService.ts +11 -0
  380. package/src/modules/cpq/api/wizards/resolve/route.ts +39 -0
  381. package/src/modules/cpq/api/wizards/route.ts +127 -0
  382. package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +166 -0
  383. package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +251 -0
  384. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.ts +9 -0
  385. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.tsx +440 -0
  386. package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +16 -0
  387. package/src/modules/cpq/backend/cpq/inventory/page.tsx +49 -0
  388. package/src/modules/cpq/backend/cpq/inventory/shared.tsx +32 -0
  389. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.ts +9 -0
  390. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +1085 -0
  391. package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +1165 -0
  392. package/src/modules/cpq/backend/cpq/offerings/page.meta.ts +16 -0
  393. package/src/modules/cpq/backend/cpq/offerings/page.tsx +468 -0
  394. package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +921 -0
  395. package/src/modules/cpq/backend/cpq/orders/page.meta.ts +16 -0
  396. package/src/modules/cpq/backend/cpq/orders/page.tsx +182 -0
  397. package/src/modules/cpq/backend/cpq/price-rules/PriceRuleForm.tsx +310 -0
  398. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.meta.ts +9 -0
  399. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.tsx +472 -0
  400. package/src/modules/cpq/backend/cpq/price-rules/page.meta.ts +16 -0
  401. package/src/modules/cpq/backend/cpq/price-rules/page.tsx +253 -0
  402. package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +431 -0
  403. package/src/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.tsx +122 -0
  404. package/src/modules/cpq/backend/cpq/pricing/[id]/page.tsx +588 -0
  405. package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.ts +90 -0
  406. package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.ts +338 -0
  407. package/src/modules/cpq/backend/cpq/pricing/page.meta.ts +16 -0
  408. package/src/modules/cpq/backend/cpq/pricing/page.tsx +111 -0
  409. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.tsx +737 -0
  410. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +1721 -0
  411. package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +16 -0
  412. package/src/modules/cpq/backend/cpq/quotes/page.tsx +370 -0
  413. package/src/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.tsx +300 -0
  414. package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +1722 -0
  415. package/src/modules/cpq/backend/cpq/specifications/page.meta.ts +16 -0
  416. package/src/modules/cpq/backend/cpq/specifications/page.tsx +191 -0
  417. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.tsx +319 -0
  418. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/page.tsx +234 -0
  419. package/src/modules/cpq/backend/cpq/wizards/page.meta.ts +16 -0
  420. package/src/modules/cpq/backend/cpq/wizards/page.tsx +141 -0
  421. package/src/modules/cpq/ce.ts +150 -0
  422. package/src/modules/cpq/cli.ts +10 -0
  423. package/src/modules/cpq/components/CpqListView.tsx +431 -0
  424. package/src/modules/cpq/components/NumberInput.tsx +166 -0
  425. package/src/modules/cpq/components/StatusPath.tsx +116 -0
  426. package/src/modules/cpq/components/statusMaps.ts +148 -0
  427. package/src/modules/cpq/components/useCpqRowActions.tsx +183 -0
  428. package/src/modules/cpq/data/__tests__/validators.arc.test.ts +271 -0
  429. package/src/modules/cpq/data/__tests__/validators.test.ts +782 -0
  430. package/src/modules/cpq/data/entities.ts +1467 -0
  431. package/src/modules/cpq/data/validators.ts +663 -0
  432. package/src/modules/cpq/di.ts +70 -0
  433. package/src/modules/cpq/events.ts +62 -0
  434. package/src/modules/cpq/index.ts +15 -0
  435. package/src/modules/cpq/lib/seeds/__tests__/api.test.ts +86 -0
  436. package/src/modules/cpq/lib/seeds/__tests__/tenant-provisioning.test.ts +349 -0
  437. package/src/modules/cpq/lib/seeds/api.ts +119 -0
  438. package/src/modules/cpq/lib/seeds/tenant-provisioning.ts +239 -0
  439. package/src/modules/cpq/lib/seeds.ts +18 -0
  440. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +7473 -0
  441. package/src/modules/cpq/migrations/Migration20260220220000_cpq.ts +51 -0
  442. package/src/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.ts +70 -0
  443. package/src/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.ts +15 -0
  444. package/src/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.ts +73 -0
  445. package/src/modules/cpq/migrations/Migration20260402000000_cpq_workflows.ts +59 -0
  446. package/src/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.ts +30 -0
  447. package/src/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.ts +36 -0
  448. package/src/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.ts +33 -0
  449. package/src/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.ts +22 -0
  450. package/src/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.ts +56 -0
  451. package/src/modules/cpq/migrations/Migration20260410100000_cpq_inventory.ts +118 -0
  452. package/src/modules/cpq/migrations/Migration20260411000000_cpq_orders.ts +62 -0
  453. package/src/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.ts +33 -0
  454. package/src/modules/cpq/migrations/Migration20260504064621_cpq_arc.ts +68 -0
  455. package/src/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.ts +30 -0
  456. package/src/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.ts +27 -0
  457. package/src/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.ts +82 -0
  458. package/src/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.ts +52 -0
  459. package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +59 -0
  460. package/src/modules/cpq/services/__tests__/cpqBundleService.test.ts +661 -0
  461. package/src/modules/cpq/services/__tests__/cpqInventoryService.arc.test.ts +357 -0
  462. package/src/modules/cpq/services/__tests__/cpqInventoryService.test.ts +718 -0
  463. package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +603 -0
  464. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +1012 -0
  465. package/src/modules/cpq/services/__tests__/cpqProductService.test.ts +603 -0
  466. package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +143 -0
  467. package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +658 -0
  468. package/src/modules/cpq/services/__tests__/cpqValidationService.test.ts +811 -0
  469. package/src/modules/cpq/services/__tests__/cpqWizardService.test.ts +389 -0
  470. package/src/modules/cpq/services/__tests__/types.test.ts +359 -0
  471. package/src/modules/cpq/services/cpqBundleService.ts +671 -0
  472. package/src/modules/cpq/services/cpqInventoryService.ts +1929 -0
  473. package/src/modules/cpq/services/cpqOrderService.ts +1230 -0
  474. package/src/modules/cpq/services/cpqPricingService.ts +412 -0
  475. package/src/modules/cpq/services/cpqProductService.ts +311 -0
  476. package/src/modules/cpq/services/cpqQuotingService.ts +2638 -0
  477. package/src/modules/cpq/services/cpqValidationService.ts +323 -0
  478. package/src/modules/cpq/services/cpqWizardService.ts +204 -0
  479. package/src/modules/cpq/services/types.ts +303 -0
  480. package/src/modules/cpq/setup.ts +63 -0
  481. package/src/modules/cpq/subscribers/subscription-amended-log.ts +20 -0
  482. package/src/modules/cpq/subscribers/subscription-cancelled-log.ts +19 -0
  483. package/src/modules/cpq/subscribers/subscription-merged-log.ts +22 -0
  484. package/src/modules/cpq/subscribers/subscription-renewed-log.ts +18 -0
  485. package/src/modules/cpq/subscribers/subscription-superseded-log.ts +19 -0
  486. package/src/modules/cpq/widgets/injection/customer-inventory/widget.client.tsx +339 -0
  487. package/src/modules/cpq/widgets/injection/customer-inventory/widget.ts +16 -0
  488. package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.client.tsx +96 -0
  489. package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.ts +16 -0
  490. package/src/modules/cpq/widgets/injection/quote-configurator/widget.client.tsx +265 -0
  491. package/src/modules/cpq/widgets/injection/quote-configurator/widget.ts +16 -0
  492. package/src/modules/cpq/widgets/injection-table.ts +42 -0
  493. package/src/modules/cpq/workflows/WizardRunner.tsx +158 -0
  494. package/src/modules/cpq/workflows/WizardTrigger.tsx +142 -0
  495. package/src/modules/cpq/workflows/engine.tsx +278 -0
  496. package/src/modules/cpq/workflows/registry.ts +15 -0
  497. package/src/modules/cpq/workflows/steps/CustomerSelectStep.tsx +136 -0
  498. package/src/modules/cpq/workflows/steps/InventorySelectStep.tsx +34 -0
  499. package/src/modules/cpq/workflows/steps/ItemListStep.tsx +146 -0
  500. package/src/modules/cpq/workflows/steps/OfferingSelectStep.tsx +438 -0
  501. package/src/modules/cpq/workflows/steps/ProductConfigureStep.tsx +287 -0
  502. package/src/modules/cpq/workflows/steps/ReviewStep.tsx +219 -0
  503. package/src/modules/cpq/workflows/steps/index.ts +51 -0
  504. package/src/modules/cpq/workflows/types.ts +102 -0
@@ -0,0 +1,1929 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ CpqInventorySubscription,
4
+ CpqInventorySubscriptionItem,
5
+ CpqInventoryAsset,
6
+ CpqProductSpecification,
7
+ CpqSubscriptionChangeLog,
8
+ } from '../data/entities'
9
+ import {
10
+ INVENTORY_SUBSCRIPTION_TRANSITIONS,
11
+ INVENTORY_ASSET_TRANSITIONS,
12
+ getBaseCurrencyCode,
13
+ type ChangeLogType,
14
+ type InventorySubscriptionStatus,
15
+ type InventoryAssetStatus,
16
+ type TenantScope,
17
+ } from './types'
18
+
19
+ // ─── Filter / Input / Result types ───────────────────────────────
20
+
21
+ interface SubscriptionListFilters {
22
+ customerId?: string
23
+ sourceOrderId?: string
24
+ status?: string | string[]
25
+ productId?: string
26
+ billingCycle?: string
27
+ search?: string
28
+ /** Only subscriptions whose currentTermEnd falls within the next N days. */
29
+ expiringWithinDays?: number
30
+ sortField?: 'createdAt' | 'updatedAt' | 'code' | 'name' | 'status' | 'startDate' | 'currentTermEnd'
31
+ sortDir?: 'asc' | 'desc'
32
+ page?: number
33
+ pageSize?: number
34
+ }
35
+
36
+ interface SubscriptionItemListFilters {
37
+ subscriptionId?: string
38
+ parentItemId?: string
39
+ status?: string
40
+ }
41
+
42
+ interface AssetListFilters {
43
+ customerId?: string
44
+ status?: string | string[]
45
+ assetType?: string
46
+ productId?: string
47
+ subscriptionId?: string
48
+ subscriptionItemId?: string
49
+ search?: string
50
+ sortField?: 'createdAt' | 'updatedAt' | 'code' | 'name' | 'status' | 'assetType' | 'purchasePrice'
51
+ sortDir?: 'asc' | 'desc'
52
+ page?: number
53
+ pageSize?: number
54
+ }
55
+
56
+ interface CustomerInventoryFilters {
57
+ status?: string | string[]
58
+ productId?: string
59
+ includeTerminated?: boolean
60
+ }
61
+
62
+ // ─── ARC (Amend / Renew / Cancel) — XD-250 input shapes ─────────
63
+
64
+ export type ArcLineAction = 'add' | 'modify' | 'cancel'
65
+
66
+ export interface ArcLineChange {
67
+ /** Source quote line id (for traceability + sourceQuoteLineId on items). */
68
+ quoteLineId?: string | null
69
+ action: ArcLineAction
70
+ /** Required for action ∈ {cancel, modify}. */
71
+ sourceSubscriptionItemId?: string | null
72
+ /** Required for action='add'. Used to materialize the new item. */
73
+ itemInput?: Omit<CreateSubscriptionItemInput, 'name'> & { name: string }
74
+ /** XD-250 ARC: source quote line's `parentLineId`. When set, applyLineChanges
75
+ * resolves the resulting `parentItemId` on the new sub item — either from
76
+ * the parent's `sourceSubscriptionItemId` (if parent is a modify line) or
77
+ * from another `add` line's pre-generated id (if parent is also brand new
78
+ * in this apply). Without this, bundle children added through ARC are
79
+ * created as standalone sub items, not nested under the bundle. */
80
+ parentQuoteLineId?: string | null
81
+ /** Optional metadata for action='modify' — the configuration / pricing changes. */
82
+ modifyPatch?: {
83
+ configuration?: Record<string, unknown>
84
+ charges?: Array<Record<string, unknown>>
85
+ mrcAmount?: number
86
+ nrcAmount?: number
87
+ quantity?: number
88
+ }
89
+ }
90
+
91
+ export interface ArcTermInput {
92
+ newTermStart: Date | string
93
+ newTermEnd: Date | string
94
+ newTermMonths?: number | null
95
+ }
96
+
97
+ export interface ArcMergeMeta {
98
+ newSubCode?: string | null
99
+ newSubName?: string | null
100
+ }
101
+
102
+ export interface ApplyAmendmentInput {
103
+ subscriptionId: string
104
+ lineChanges: ArcLineChange[]
105
+ sourceQuoteId: string
106
+ sourceOrderId: string
107
+ performedByUserId?: string | null
108
+ }
109
+
110
+ export interface ApplyRenewalInput extends ApplyAmendmentInput {
111
+ term: ArcTermInput
112
+ }
113
+
114
+ export interface ApplyMergeRenewalInput {
115
+ sourceIds: string[]
116
+ term: ArcTermInput
117
+ mergeMeta: ArcMergeMeta
118
+ lineChanges: ArcLineChange[]
119
+ sourceQuoteId: string
120
+ sourceOrderId: string
121
+ performedByUserId?: string | null
122
+ }
123
+
124
+ export interface ApplyCancelInput {
125
+ subscriptionId: string
126
+ sourceQuoteId: string
127
+ sourceOrderId: string
128
+ performedByUserId?: string | null
129
+ etfAmount?: number | string | null
130
+ etfCurrency?: string | null
131
+ reasonCode?: string | null
132
+ reasonText?: string | null
133
+ }
134
+
135
+ export interface ApplyArcResult {
136
+ changeLog: CpqSubscriptionChangeLog
137
+ /** Subscription as left after mutation (for amend / renew / cancel) or M (for merge-result). */
138
+ subscription: CpqInventorySubscription
139
+ }
140
+
141
+ export interface ApplyMergeRenewalResult {
142
+ /** The new merge sub M. */
143
+ mergedSubscription: CpqInventorySubscription
144
+ /** The merge-result ChangeLog row (on M). */
145
+ mergeResultChangeLog: CpqSubscriptionChangeLog
146
+ /** One per source — change_type='merge-source'. */
147
+ sourceChangeLogs: CpqSubscriptionChangeLog[]
148
+ }
149
+
150
+ export interface CreateSubscriptionInput {
151
+ customerId: string
152
+ code?: string
153
+ name: string
154
+ description?: string
155
+ billingCycle?: string
156
+ currencyCode?: string
157
+ startDate?: string
158
+ termMonths?: number
159
+ autoRenew?: boolean
160
+ sourceQuoteId?: string
161
+ sourceOrderId?: string
162
+ metadata?: Record<string, unknown>
163
+ items: CreateSubscriptionItemInput[]
164
+ }
165
+
166
+ export interface CreateSubscriptionItemInput {
167
+ parentItemId?: string
168
+ productId?: string
169
+ offeringId?: string
170
+ specId?: string
171
+ name: string
172
+ configuration?: Record<string, unknown>
173
+ charges?: Array<Record<string, unknown>>
174
+ mrcAmount?: number
175
+ nrcAmount?: number
176
+ currencyCode?: string
177
+ quantity?: number
178
+ capacityTotal?: number
179
+ capacityUnit?: string
180
+ sourceQuoteLineId?: string
181
+ metadata?: Record<string, unknown>
182
+ sortOrder?: number
183
+ }
184
+
185
+ interface UpdateSubscriptionInput {
186
+ name?: string
187
+ description?: string
188
+ billingCycle?: string
189
+ autoRenew?: boolean
190
+ termMonths?: number
191
+ currentTermEnd?: string
192
+ metadata?: Record<string, unknown>
193
+ }
194
+
195
+ interface UpdateSubscriptionItemInput {
196
+ name?: string
197
+ metadata?: Record<string, unknown>
198
+ capacityTotal?: number
199
+ capacityUsed?: number
200
+ capacityUnit?: string
201
+ sortOrder?: number
202
+ }
203
+
204
+ interface CreateAssetInput {
205
+ customerId: string
206
+ subscriptionId?: string
207
+ subscriptionItemId?: string
208
+ productId?: string
209
+ offeringId?: string
210
+ specId?: string
211
+ code?: string
212
+ name: string
213
+ description?: string
214
+ assetType?: string
215
+ currencyCode?: string
216
+ purchasePrice?: number
217
+ quantity?: number
218
+ purchaseDate?: string
219
+ deliveryDate?: string
220
+ configuration?: Record<string, unknown>
221
+ charges?: Array<Record<string, unknown>>
222
+ sourceQuoteId?: string
223
+ sourceOrderId?: string
224
+ sourceQuoteLineId?: string
225
+ metadata?: Record<string, unknown>
226
+ }
227
+
228
+ interface UpdateAssetInput {
229
+ name?: string
230
+ description?: string
231
+ assetType?: string
232
+ deliveryDate?: string
233
+ metadata?: Record<string, unknown>
234
+ }
235
+
236
+ interface PaginatedResult<T> {
237
+ items: T[]
238
+ total: number
239
+ page: number
240
+ pageSize: number
241
+ totalPages: number
242
+ }
243
+
244
+ interface SubscriptionItemResultNode {
245
+ id: string
246
+ subscriptionId: string
247
+ parentItemId: string | null
248
+ productId: string | null
249
+ offeringId: string | null
250
+ specId: string | null
251
+ name: string
252
+ status: string
253
+ configuration: Record<string, unknown>
254
+ charges: Array<Record<string, unknown>> | null
255
+ mrcAmount: number
256
+ nrcAmount: number
257
+ currencyCode: string
258
+ quantity: number
259
+ capacityTotal: number | null
260
+ capacityUsed: number | null
261
+ capacityUnit: string | null
262
+ metadata: Record<string, unknown> | null
263
+ sortOrder: number
264
+ children: SubscriptionItemResultNode[]
265
+ }
266
+
267
+ // ─── Service implementation ──────────────────────────────────────
268
+
269
+ export class DefaultCpqInventoryService {
270
+ private em: EntityManager
271
+
272
+ constructor(em: EntityManager) {
273
+ this.em = em
274
+ }
275
+
276
+ // ═══════════════════════════════════════════════════════════════
277
+ // Subscriptions
278
+ // ═══════════════════════════════════════════════════════════════
279
+
280
+ async listSubscriptions(filters: SubscriptionListFilters, scope: TenantScope): Promise<PaginatedResult<CpqInventorySubscription>> {
281
+ const page = Math.max(1, filters.page ?? 1)
282
+ const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 50))
283
+ const offset = (page - 1) * pageSize
284
+
285
+ const where: Record<string, unknown> = {
286
+ organizationId: scope.organizationId,
287
+ tenantId: scope.tenantId,
288
+ deletedAt: null,
289
+ }
290
+
291
+ if (filters.customerId) where.customerId = filters.customerId
292
+ if (filters.sourceOrderId) where.sourceOrderId = filters.sourceOrderId
293
+ if (filters.billingCycle) where.billingCycle = filters.billingCycle
294
+ if (filters.status) {
295
+ where.status = Array.isArray(filters.status)
296
+ ? { $in: filters.status }
297
+ : filters.status
298
+ }
299
+
300
+ const search = filters.search?.trim()
301
+ if (search) {
302
+ where.$or = [
303
+ { code: { $ilike: `%${search}%` } },
304
+ { name: { $ilike: `%${search}%` } },
305
+ ]
306
+ }
307
+
308
+ if (filters.productId) {
309
+ const itemIds = await this.em.find(
310
+ CpqInventorySubscriptionItem,
311
+ {
312
+ organizationId: scope.organizationId,
313
+ tenantId: scope.tenantId,
314
+ deletedAt: null,
315
+ productId: filters.productId,
316
+ },
317
+ { fields: ['subscriptionId'] as const },
318
+ )
319
+ const subIds = [...new Set(itemIds.map((i) => i.subscriptionId))]
320
+ if (subIds.length === 0) {
321
+ return { items: [], total: 0, page, pageSize, totalPages: 0 }
322
+ }
323
+ where.id = { $in: subIds }
324
+ }
325
+
326
+ // Expiring window: subscriptions whose currentTermEnd is between now
327
+ // and now + N days. Replaces the separate /expiring page — operators
328
+ // save this as a perspective on the main list.
329
+ if (filters.expiringWithinDays != null && filters.expiringWithinDays > 0) {
330
+ const now = new Date()
331
+ const cutoff = new Date(now.getTime() + filters.expiringWithinDays * 24 * 60 * 60 * 1000)
332
+ where.currentTermEnd = { $gte: now, $lte: cutoff }
333
+ }
334
+
335
+ // Default sort for the expiring view is "soonest first"; otherwise
336
+ // the caller's choice (or newest-first).
337
+ const defaultSortField =
338
+ filters.expiringWithinDays != null && filters.expiringWithinDays > 0
339
+ ? 'currentTermEnd'
340
+ : 'createdAt'
341
+ const sortField = filters.sortField ?? defaultSortField
342
+ const sortDir = filters.sortDir === 'asc' ? 'asc' :
343
+ filters.sortField === undefined && filters.expiringWithinDays != null ? 'asc' : 'desc'
344
+
345
+ const [items, total] = await this.em.findAndCount(
346
+ CpqInventorySubscription,
347
+ where,
348
+ { limit: pageSize, offset, orderBy: { [sortField]: sortDir } },
349
+ )
350
+
351
+ return {
352
+ items,
353
+ total,
354
+ page,
355
+ pageSize,
356
+ totalPages: Math.ceil(total / pageSize),
357
+ }
358
+ }
359
+
360
+ async getSubscription(subscriptionId: string, scope: TenantScope) {
361
+ const sub = await this.em.findOne(CpqInventorySubscription, {
362
+ id: subscriptionId,
363
+ organizationId: scope.organizationId,
364
+ tenantId: scope.tenantId,
365
+ deletedAt: null,
366
+ })
367
+ if (!sub) throw { status: 404, error: 'Subscription not found' }
368
+
369
+ const allItems = await this.em.find(CpqInventorySubscriptionItem, {
370
+ subscriptionId: sub.id,
371
+ organizationId: scope.organizationId,
372
+ tenantId: scope.tenantId,
373
+ deletedAt: null,
374
+ }, { orderBy: { sortOrder: 'asc' } })
375
+
376
+ const itemTree = this.buildItemTree(allItems)
377
+
378
+ return { ...sub, items: itemTree }
379
+ }
380
+
381
+ async createSubscription(input: CreateSubscriptionInput, scope: TenantScope) {
382
+ const code = input.code ?? await this.generateSubscriptionCode(scope)
383
+
384
+ const subId = crypto.randomUUID()
385
+ const sub = this.em.create(CpqInventorySubscription, {
386
+ id: subId,
387
+ organizationId: scope.organizationId,
388
+ tenantId: scope.tenantId,
389
+ customerId: input.customerId,
390
+ code,
391
+ name: input.name,
392
+ description: input.description ?? null,
393
+ status: 'pending',
394
+ billingCycle: input.billingCycle ?? 'monthly',
395
+ currencyCode: input.currencyCode ?? await getBaseCurrencyCode(this.em, scope),
396
+ mrcAmount: '0',
397
+ nrcAmount: '0',
398
+ startDate: input.startDate ? new Date(input.startDate) : null,
399
+ termMonths: input.termMonths ?? null,
400
+ autoRenew: input.autoRenew ?? true,
401
+ sourceQuoteId: input.sourceQuoteId ?? null,
402
+ sourceOrderId: input.sourceOrderId ?? null,
403
+ metadata: input.metadata ?? null,
404
+ })
405
+ this.em.persist(sub)
406
+
407
+ const items: CpqInventorySubscriptionItem[] = []
408
+ for (const itemInput of input.items) {
409
+ const item = this.em.create(CpqInventorySubscriptionItem, {
410
+ organizationId: scope.organizationId,
411
+ tenantId: scope.tenantId,
412
+ subscriptionId: sub.id,
413
+ customerId: input.customerId,
414
+ parentItemId: itemInput.parentItemId ?? null,
415
+ productId: itemInput.productId ?? null,
416
+ offeringId: itemInput.offeringId ?? null,
417
+ specId: itemInput.specId ?? null,
418
+ name: itemInput.name,
419
+ status: 'pending',
420
+ configuration: itemInput.configuration ?? {},
421
+ charges: itemInput.charges ?? null,
422
+ mrcAmount: String(itemInput.mrcAmount ?? 0),
423
+ nrcAmount: String(itemInput.nrcAmount ?? 0),
424
+ currencyCode: itemInput.currencyCode ?? input.currencyCode ?? await getBaseCurrencyCode(this.em, scope),
425
+ quantity: itemInput.quantity ?? 1,
426
+ capacityTotal: itemInput.capacityTotal != null ? String(itemInput.capacityTotal) : null,
427
+ capacityUsed: null,
428
+ capacityUnit: itemInput.capacityUnit ?? null,
429
+ sourceQuoteLineId: itemInput.sourceQuoteLineId ?? null,
430
+ metadata: itemInput.metadata ?? null,
431
+ sortOrder: itemInput.sortOrder ?? 0,
432
+ })
433
+ this.em.persist(item)
434
+ items.push(item)
435
+ }
436
+
437
+ const mrcSum = items.reduce((s, i) => s + Number(i.mrcAmount), 0)
438
+ const nrcSum = items.reduce((s, i) => s + Number(i.nrcAmount), 0)
439
+ sub.mrcAmount = String(mrcSum)
440
+ sub.nrcAmount = String(nrcSum)
441
+
442
+ // Seed the change log with a `created` entry so the Change History
443
+ // panel always shows where the subscription came from — same shape
444
+ // as the `amend` / `renew` / `cancel` entries written by ARC flows.
445
+ // beforeSnapshot is null because there is no prior state; the after
446
+ // snapshot is the freshly-built subscription so the UI can diff
447
+ // against subsequent changes.
448
+ await this.em.flush() // need sub.id and items persisted before snapshot
449
+ const afterSnapshot = await this.snapshotSubscription(sub, scope)
450
+ const log = this.createChangeLog({
451
+ subscription: sub,
452
+ changeType: 'created',
453
+ sourceQuoteId: input.sourceQuoteId ?? null,
454
+ sourceOrderId: input.sourceOrderId ?? null,
455
+ performedByUserId: null,
456
+ beforeSnapshot: null,
457
+ afterSnapshot,
458
+ lineChanges: items.map((item) => ({
459
+ action: 'add' as const,
460
+ itemId: item.id,
461
+ sourceQuoteLineId: item.sourceQuoteLineId ?? null,
462
+ mrcAmount: Number(item.mrcAmount),
463
+ nrcAmount: Number(item.nrcAmount),
464
+ quantity: item.quantity,
465
+ })),
466
+ })
467
+ sub.lastChangeLogId = log.id
468
+ await this.em.flush()
469
+ return sub
470
+ }
471
+
472
+ async updateSubscription(id: string, updates: UpdateSubscriptionInput, scope: TenantScope) {
473
+ const sub = await this.em.findOne(CpqInventorySubscription, {
474
+ id,
475
+ organizationId: scope.organizationId,
476
+ tenantId: scope.tenantId,
477
+ deletedAt: null,
478
+ })
479
+ if (!sub) throw { status: 404, error: 'Subscription not found' }
480
+
481
+ if (updates.name !== undefined) sub.name = updates.name
482
+ if (updates.description !== undefined) sub.description = updates.description
483
+ if (updates.billingCycle !== undefined) sub.billingCycle = updates.billingCycle
484
+ if (updates.autoRenew !== undefined) sub.autoRenew = updates.autoRenew
485
+ if (updates.termMonths !== undefined) sub.termMonths = updates.termMonths
486
+ if (updates.currentTermEnd !== undefined) sub.currentTermEnd = updates.currentTermEnd ? new Date(updates.currentTermEnd) : null
487
+ if (updates.metadata !== undefined) sub.metadata = updates.metadata
488
+
489
+ await this.em.flush()
490
+ return sub
491
+ }
492
+
493
+ async deleteSubscription(id: string, scope: TenantScope) {
494
+ const sub = await this.em.findOne(CpqInventorySubscription, {
495
+ id,
496
+ organizationId: scope.organizationId,
497
+ tenantId: scope.tenantId,
498
+ deletedAt: null,
499
+ })
500
+ if (!sub) throw { status: 404, error: 'Subscription not found' }
501
+
502
+ sub.deletedAt = new Date()
503
+ await this.em.flush()
504
+ return { success: true }
505
+ }
506
+
507
+ async transitionSubscriptionStatus(id: string, targetStatus: string, scope: TenantScope) {
508
+ const sub = await this.em.findOne(CpqInventorySubscription, {
509
+ id,
510
+ organizationId: scope.organizationId,
511
+ tenantId: scope.tenantId,
512
+ deletedAt: null,
513
+ })
514
+ if (!sub) throw { status: 404, error: 'Subscription not found' }
515
+
516
+ const currentStatus = sub.status as InventorySubscriptionStatus
517
+ const allowed = INVENTORY_SUBSCRIPTION_TRANSITIONS[currentStatus] ?? []
518
+ if (!allowed.includes(targetStatus as InventorySubscriptionStatus)) {
519
+ throw { status: 409, error: `Cannot transition from '${sub.status}' to '${targetStatus}'` }
520
+ }
521
+
522
+ const now = new Date()
523
+
524
+ if (targetStatus === 'active' && !sub.activatedAt) {
525
+ sub.activatedAt = now
526
+ }
527
+ if (targetStatus === 'suspended') {
528
+ sub.suspendedAt = now
529
+ }
530
+ if (currentStatus === 'suspended' && targetStatus === 'active') {
531
+ sub.suspendedAt = null
532
+ }
533
+ if (targetStatus === 'terminated' || targetStatus === 'superseded') {
534
+ sub.terminatedAt = now
535
+ }
536
+
537
+ const items = await this.em.find(CpqInventorySubscriptionItem, {
538
+ subscriptionId: sub.id,
539
+ organizationId: scope.organizationId,
540
+ tenantId: scope.tenantId,
541
+ deletedAt: null,
542
+ status: { $nin: ['terminated', 'expired', 'superseded'] },
543
+ })
544
+ for (const item of items) {
545
+ item.status = targetStatus
546
+ }
547
+
548
+ if (targetStatus === 'active' || targetStatus === 'terminated' || targetStatus === 'superseded') {
549
+ const assetTargetStatus = targetStatus === 'active' ? 'active' : 'cancelled'
550
+ const excludeStatuses = targetStatus === 'active'
551
+ ? ['returned']
552
+ : ['returned', 'cancelled']
553
+ const assets = await this.em.find(CpqInventoryAsset, {
554
+ subscriptionId: sub.id,
555
+ organizationId: scope.organizationId,
556
+ tenantId: scope.tenantId,
557
+ deletedAt: null,
558
+ status: { $nin: excludeStatuses },
559
+ })
560
+ for (const asset of assets) {
561
+ asset.status = assetTargetStatus
562
+ }
563
+ }
564
+
565
+ sub.status = targetStatus
566
+ await this.em.flush()
567
+ return sub
568
+ }
569
+
570
+ // ═══════════════════════════════════════════════════════════════
571
+ // Subscription Items
572
+ // ═══════════════════════════════════════════════════════════════
573
+
574
+ async listSubscriptionItems(filters: SubscriptionItemListFilters, scope: TenantScope) {
575
+ const where: Record<string, unknown> = {
576
+ organizationId: scope.organizationId,
577
+ tenantId: scope.tenantId,
578
+ deletedAt: null,
579
+ }
580
+
581
+ if (filters.subscriptionId) where.subscriptionId = filters.subscriptionId
582
+ if (filters.parentItemId) where.parentItemId = filters.parentItemId
583
+ if (filters.status) where.status = filters.status
584
+
585
+ return this.em.find(CpqInventorySubscriptionItem, where, {
586
+ orderBy: { sortOrder: 'asc' },
587
+ })
588
+ }
589
+
590
+ async addSubscriptionItem(subscriptionId: string, input: CreateSubscriptionItemInput, scope: TenantScope) {
591
+ const sub = await this.em.findOne(CpqInventorySubscription, {
592
+ id: subscriptionId,
593
+ organizationId: scope.organizationId,
594
+ tenantId: scope.tenantId,
595
+ deletedAt: null,
596
+ })
597
+ if (!sub) throw { status: 404, error: 'Subscription not found' }
598
+
599
+ if (input.parentItemId) {
600
+ const parent = await this.em.findOne(CpqInventorySubscriptionItem, {
601
+ id: input.parentItemId,
602
+ organizationId: scope.organizationId,
603
+ tenantId: scope.tenantId,
604
+ deletedAt: null,
605
+ })
606
+ if (!parent) throw { status: 404, error: 'Parent item not found' }
607
+ if (parent.subscriptionId !== subscriptionId) {
608
+ throw { status: 409, error: 'Parent item does not belong to this subscription' }
609
+ }
610
+ }
611
+
612
+ const item = this.em.create(CpqInventorySubscriptionItem, {
613
+ organizationId: scope.organizationId,
614
+ tenantId: scope.tenantId,
615
+ subscriptionId,
616
+ customerId: sub.customerId,
617
+ parentItemId: input.parentItemId ?? null,
618
+ productId: input.productId ?? null,
619
+ offeringId: input.offeringId ?? null,
620
+ specId: input.specId ?? null,
621
+ name: input.name,
622
+ status: 'pending',
623
+ configuration: input.configuration ?? {},
624
+ charges: input.charges ?? null,
625
+ mrcAmount: String(input.mrcAmount ?? 0),
626
+ nrcAmount: String(input.nrcAmount ?? 0),
627
+ currencyCode: input.currencyCode ?? sub.currencyCode,
628
+ quantity: input.quantity ?? 1,
629
+ capacityTotal: input.capacityTotal != null ? String(input.capacityTotal) : null,
630
+ capacityUsed: null,
631
+ capacityUnit: input.capacityUnit ?? null,
632
+ sourceQuoteLineId: input.sourceQuoteLineId ?? null,
633
+ metadata: input.metadata ?? null,
634
+ sortOrder: input.sortOrder ?? 0,
635
+ })
636
+ this.em.persist(item)
637
+
638
+ await this.em.flush()
639
+ await this.recomputeSubscriptionAggregates(subscriptionId, scope)
640
+
641
+ return item
642
+ }
643
+
644
+ async updateSubscriptionItem(id: string, updates: UpdateSubscriptionItemInput, scope: TenantScope) {
645
+ const item = await this.em.findOne(CpqInventorySubscriptionItem, {
646
+ id,
647
+ organizationId: scope.organizationId,
648
+ tenantId: scope.tenantId,
649
+ deletedAt: null,
650
+ })
651
+ if (!item) throw { status: 404, error: 'Subscription item not found' }
652
+
653
+ if (updates.name !== undefined) item.name = updates.name
654
+ if (updates.metadata !== undefined) item.metadata = updates.metadata
655
+ if (updates.capacityTotal !== undefined) item.capacityTotal = updates.capacityTotal != null ? String(updates.capacityTotal) : null
656
+ if (updates.capacityUsed !== undefined) item.capacityUsed = updates.capacityUsed != null ? String(updates.capacityUsed) : null
657
+ if (updates.capacityUnit !== undefined) item.capacityUnit = updates.capacityUnit
658
+ if (updates.sortOrder !== undefined) item.sortOrder = updates.sortOrder
659
+
660
+ await this.em.flush()
661
+ return item
662
+ }
663
+
664
+ async deleteSubscriptionItem(id: string, scope: TenantScope) {
665
+ const item = await this.em.findOne(CpqInventorySubscriptionItem, {
666
+ id,
667
+ organizationId: scope.organizationId,
668
+ tenantId: scope.tenantId,
669
+ deletedAt: null,
670
+ })
671
+ if (!item) throw { status: 404, error: 'Subscription item not found' }
672
+
673
+ item.deletedAt = new Date()
674
+ await this.em.flush()
675
+ await this.recomputeSubscriptionAggregates(item.subscriptionId, scope)
676
+
677
+ return { success: true }
678
+ }
679
+
680
+ // ═══════════════════════════════════════════════════════════════
681
+ // Assets
682
+ // ═══════════════════════════════════════════════════════════════
683
+
684
+ async listAssets(filters: AssetListFilters, scope: TenantScope): Promise<PaginatedResult<CpqInventoryAsset>> {
685
+ const page = Math.max(1, filters.page ?? 1)
686
+ const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 50))
687
+ const offset = (page - 1) * pageSize
688
+
689
+ const where: Record<string, unknown> = {
690
+ organizationId: scope.organizationId,
691
+ tenantId: scope.tenantId,
692
+ deletedAt: null,
693
+ }
694
+
695
+ if (filters.customerId) where.customerId = filters.customerId
696
+ if (filters.status) {
697
+ where.status = Array.isArray(filters.status)
698
+ ? { $in: filters.status }
699
+ : filters.status
700
+ }
701
+ if (filters.assetType) where.assetType = filters.assetType
702
+ if (filters.productId) where.productId = filters.productId
703
+ if (filters.subscriptionId) where.subscriptionId = filters.subscriptionId
704
+ if (filters.subscriptionItemId) where.subscriptionItemId = filters.subscriptionItemId
705
+
706
+ const search = filters.search?.trim()
707
+ if (search) {
708
+ where.$or = [
709
+ { code: { $ilike: `%${search}%` } },
710
+ { name: { $ilike: `%${search}%` } },
711
+ ]
712
+ }
713
+
714
+ const sortField = filters.sortField ?? 'createdAt'
715
+ const sortDir = filters.sortDir === 'asc' ? 'asc' : 'desc'
716
+
717
+ const [items, total] = await this.em.findAndCount(
718
+ CpqInventoryAsset,
719
+ where,
720
+ { limit: pageSize, offset, orderBy: { [sortField]: sortDir } },
721
+ )
722
+
723
+ return {
724
+ items,
725
+ total,
726
+ page,
727
+ pageSize,
728
+ totalPages: Math.ceil(total / pageSize),
729
+ }
730
+ }
731
+
732
+ async getAsset(assetId: string, scope: TenantScope) {
733
+ const asset = await this.em.findOne(CpqInventoryAsset, {
734
+ id: assetId,
735
+ organizationId: scope.organizationId,
736
+ tenantId: scope.tenantId,
737
+ deletedAt: null,
738
+ })
739
+ if (!asset) throw { status: 404, error: 'Asset not found' }
740
+ return asset
741
+ }
742
+
743
+ async createAsset(input: CreateAssetInput, scope: TenantScope) {
744
+ const code = input.code ?? await this.generateAssetCode(scope)
745
+
746
+ if (input.subscriptionId) {
747
+ const sub = await this.em.findOne(CpqInventorySubscription, {
748
+ id: input.subscriptionId,
749
+ organizationId: scope.organizationId,
750
+ tenantId: scope.tenantId,
751
+ deletedAt: null,
752
+ })
753
+ if (!sub) throw { status: 404, error: 'Subscription not found' }
754
+ }
755
+
756
+ if (input.subscriptionItemId) {
757
+ const item = await this.em.findOne(CpqInventorySubscriptionItem, {
758
+ id: input.subscriptionItemId,
759
+ organizationId: scope.organizationId,
760
+ tenantId: scope.tenantId,
761
+ deletedAt: null,
762
+ })
763
+ if (!item) throw { status: 404, error: 'Subscription item not found' }
764
+ }
765
+
766
+ const asset = this.em.create(CpqInventoryAsset, {
767
+ organizationId: scope.organizationId,
768
+ tenantId: scope.tenantId,
769
+ customerId: input.customerId,
770
+ subscriptionId: input.subscriptionId ?? null,
771
+ subscriptionItemId: input.subscriptionItemId ?? null,
772
+ productId: input.productId ?? null,
773
+ offeringId: input.offeringId ?? null,
774
+ specId: input.specId ?? null,
775
+ code,
776
+ name: input.name,
777
+ description: input.description ?? null,
778
+ status: 'pending',
779
+ assetType: input.assetType ?? 'one_time',
780
+ currencyCode: input.currencyCode ?? await getBaseCurrencyCode(this.em, scope),
781
+ purchasePrice: String(input.purchasePrice ?? 0),
782
+ quantity: input.quantity ?? 1,
783
+ purchaseDate: input.purchaseDate ? new Date(input.purchaseDate) : null,
784
+ deliveryDate: input.deliveryDate ? new Date(input.deliveryDate) : null,
785
+ configuration: input.configuration ?? {},
786
+ charges: input.charges ?? null,
787
+ sourceQuoteId: input.sourceQuoteId ?? null,
788
+ sourceOrderId: input.sourceOrderId ?? null,
789
+ sourceQuoteLineId: input.sourceQuoteLineId ?? null,
790
+ metadata: input.metadata ?? null,
791
+ })
792
+ this.em.persist(asset)
793
+
794
+ await this.em.flush()
795
+ return asset
796
+ }
797
+
798
+ async updateAsset(id: string, updates: UpdateAssetInput, scope: TenantScope) {
799
+ const asset = await this.em.findOne(CpqInventoryAsset, {
800
+ id,
801
+ organizationId: scope.organizationId,
802
+ tenantId: scope.tenantId,
803
+ deletedAt: null,
804
+ })
805
+ if (!asset) throw { status: 404, error: 'Asset not found' }
806
+
807
+ if (updates.name !== undefined) asset.name = updates.name
808
+ if (updates.description !== undefined) asset.description = updates.description
809
+ if (updates.assetType !== undefined) asset.assetType = updates.assetType
810
+ if (updates.deliveryDate !== undefined) asset.deliveryDate = updates.deliveryDate ? new Date(updates.deliveryDate) : null
811
+ if (updates.metadata !== undefined) asset.metadata = updates.metadata
812
+
813
+ await this.em.flush()
814
+ return asset
815
+ }
816
+
817
+ async deleteAsset(id: string, scope: TenantScope) {
818
+ const asset = await this.em.findOne(CpqInventoryAsset, {
819
+ id,
820
+ organizationId: scope.organizationId,
821
+ tenantId: scope.tenantId,
822
+ deletedAt: null,
823
+ })
824
+ if (!asset) throw { status: 404, error: 'Asset not found' }
825
+
826
+ asset.deletedAt = new Date()
827
+ await this.em.flush()
828
+ return { success: true }
829
+ }
830
+
831
+ async transitionAssetStatus(id: string, targetStatus: string, scope: TenantScope) {
832
+ const asset = await this.em.findOne(CpqInventoryAsset, {
833
+ id,
834
+ organizationId: scope.organizationId,
835
+ tenantId: scope.tenantId,
836
+ deletedAt: null,
837
+ })
838
+ if (!asset) throw { status: 404, error: 'Asset not found' }
839
+
840
+ const currentStatus = asset.status as InventoryAssetStatus
841
+ const allowed = INVENTORY_ASSET_TRANSITIONS[currentStatus] ?? []
842
+ if (!allowed.includes(targetStatus as InventoryAssetStatus)) {
843
+ throw { status: 409, error: `Cannot transition from '${asset.status}' to '${targetStatus}'` }
844
+ }
845
+
846
+ asset.status = targetStatus
847
+ await this.em.flush()
848
+ return asset
849
+ }
850
+
851
+ // ═══════════════════════════════════════════════════════════════
852
+ // Customer View
853
+ // ═══════════════════════════════════════════════════════════════
854
+
855
+ async getCustomerInventory(customerId: string, filters: CustomerInventoryFilters, scope: TenantScope) {
856
+ const baseWhere: Record<string, unknown> = {
857
+ organizationId: scope.organizationId,
858
+ tenantId: scope.tenantId,
859
+ deletedAt: null,
860
+ customerId,
861
+ }
862
+
863
+ const subTerminalStatuses = ['terminated', 'expired']
864
+ const itemTerminalStatuses = ['terminated', 'expired']
865
+ const assetTerminalStatuses = ['returned']
866
+
867
+ const subWhere: Record<string, unknown> = { ...baseWhere }
868
+ const itemWhere: Record<string, unknown> = { ...baseWhere }
869
+ const assetWhere: Record<string, unknown> = { ...baseWhere }
870
+
871
+ if (filters.status) {
872
+ const statuses = Array.isArray(filters.status) ? filters.status : [filters.status]
873
+ subWhere.status = { $in: statuses }
874
+ itemWhere.status = { $in: statuses }
875
+ assetWhere.status = { $in: statuses }
876
+ } else if (!filters.includeTerminated) {
877
+ subWhere.status = { $nin: subTerminalStatuses }
878
+ itemWhere.status = { $nin: itemTerminalStatuses }
879
+ assetWhere.status = { $nin: assetTerminalStatuses }
880
+ }
881
+
882
+ if (filters.productId) {
883
+ subWhere.id = { $in: await this.subscriptionIdsForProduct(filters.productId, scope) }
884
+ itemWhere.productId = filters.productId
885
+ assetWhere.productId = filters.productId
886
+ }
887
+
888
+ const [subscriptions, subscriptionItems, assets] = await Promise.all([
889
+ this.em.find(CpqInventorySubscription, subWhere, { orderBy: { createdAt: 'desc' } }),
890
+ this.em.find(CpqInventorySubscriptionItem, itemWhere, { orderBy: { sortOrder: 'asc' } }),
891
+ this.em.find(CpqInventoryAsset, assetWhere, { orderBy: { createdAt: 'desc' } }),
892
+ ])
893
+
894
+ const activeSubscriptions = subscriptions.filter((s) => s.status === 'active').length
895
+ const totalMrc = subscriptions
896
+ .filter((s) => s.status === 'active')
897
+ .reduce((sum, s) => sum + Number(s.mrcAmount), 0)
898
+ const activeAssets = assets.filter((a) => a.status === 'active').length
899
+ const pendingItems = subscriptionItems.filter((i) => i.status === 'pending').length
900
+
901
+ const currencyCode = subscriptions[0]?.currencyCode ?? assets[0]?.currencyCode ?? await getBaseCurrencyCode(this.em, scope)
902
+
903
+ return {
904
+ customerId,
905
+ subscriptions,
906
+ subscriptionItems,
907
+ assets,
908
+ summary: {
909
+ activeSubscriptions,
910
+ totalMrc,
911
+ activeAssets,
912
+ pendingItems,
913
+ currencyCode,
914
+ },
915
+ }
916
+ }
917
+
918
+ async getCustomerParentItems(
919
+ customerId: string,
920
+ filters: { productId?: string; offeringId?: string },
921
+ scope: TenantScope,
922
+ ) {
923
+ const where: Record<string, unknown> = {
924
+ organizationId: scope.organizationId,
925
+ tenantId: scope.tenantId,
926
+ deletedAt: null,
927
+ customerId,
928
+ parentItemId: null,
929
+ status: 'active',
930
+ }
931
+
932
+ if (filters.productId) where.productId = filters.productId
933
+ if (filters.offeringId) where.offeringId = filters.offeringId
934
+
935
+ return this.em.find(CpqInventorySubscriptionItem, where, {
936
+ orderBy: { sortOrder: 'asc' },
937
+ })
938
+ }
939
+
940
+ // ═══════════════════════════════════════════════════════════════
941
+ // ARC (Amend / Renew / Cancel) — XD-250
942
+ //
943
+ // Mutate-in-place primitives invoked by cpqOrderService.activateOrder
944
+ // per attached target subscription. Each method:
945
+ // • is idempotent on (sourceOrderId, subscriptionId), enforced via the
946
+ // DB-level UNIQUE on cpq_subscription_change_logs;
947
+ // • takes optimistic lock on subscription.version (MikroORM auto-bumps);
948
+ // • writes a CpqSubscriptionChangeLog row and updates
949
+ // subscription.lastChangeLogId.
950
+ //
951
+ // Event emission is the caller's job (centralized in cpqOrderService
952
+ // AFTER the activation transaction commits).
953
+ // ═══════════════════════════════════════════════════════════════
954
+
955
+ async findExpiringSubscriptions(
956
+ filters: {
957
+ withinDays?: number
958
+ customerId?: string
959
+ billingCycle?: string
960
+ status?: string
961
+ page?: number
962
+ pageSize?: number
963
+ },
964
+ scope: TenantScope,
965
+ ): Promise<PaginatedResult<CpqInventorySubscription>> {
966
+ const withinDays = Math.min(365, Math.max(1, filters.withinDays ?? 30))
967
+ const status = filters.status ?? 'active'
968
+ const page = Math.max(1, filters.page ?? 1)
969
+ const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 50))
970
+ const offset = (page - 1) * pageSize
971
+
972
+ const now = new Date()
973
+ const horizon = new Date(now.getTime() + withinDays * 24 * 60 * 60 * 1000)
974
+
975
+ const where: Record<string, unknown> = {
976
+ organizationId: scope.organizationId,
977
+ tenantId: scope.tenantId,
978
+ deletedAt: null,
979
+ status,
980
+ currentTermEnd: { $gte: now, $lte: horizon },
981
+ }
982
+ if (filters.customerId) where.customerId = filters.customerId
983
+ if (filters.billingCycle) where.billingCycle = filters.billingCycle
984
+
985
+ const [items, total] = await this.em.findAndCount(
986
+ CpqInventorySubscription,
987
+ where,
988
+ { limit: pageSize, offset, orderBy: { currentTermEnd: 'asc' } },
989
+ )
990
+
991
+ return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
992
+ }
993
+
994
+ async listChangeLog(
995
+ subscriptionId: string,
996
+ filters: { page?: number; pageSize?: number },
997
+ scope: TenantScope,
998
+ ): Promise<PaginatedResult<CpqSubscriptionChangeLog>> {
999
+ const page = Math.max(1, filters.page ?? 1)
1000
+ const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 50))
1001
+ const offset = (page - 1) * pageSize
1002
+
1003
+ const where = {
1004
+ organizationId: scope.organizationId,
1005
+ tenantId: scope.tenantId,
1006
+ deletedAt: null,
1007
+ subscriptionId,
1008
+ }
1009
+
1010
+ const [items, total] = await this.em.findAndCount(
1011
+ CpqSubscriptionChangeLog,
1012
+ where,
1013
+ { limit: pageSize, offset, orderBy: { createdAt: 'desc' } },
1014
+ )
1015
+
1016
+ return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
1017
+ }
1018
+
1019
+ async applyAmendment(input: ApplyAmendmentInput, scope: TenantScope): Promise<ApplyArcResult> {
1020
+ const idempotent = await this.findIdempotentChangeLog(
1021
+ input.sourceOrderId,
1022
+ input.subscriptionId,
1023
+ scope,
1024
+ )
1025
+ if (idempotent) {
1026
+ const sub = await this.requireSubscription(input.subscriptionId, scope)
1027
+ return { changeLog: idempotent, subscription: sub }
1028
+ }
1029
+
1030
+ const sub = await this.requireSubscription(input.subscriptionId, scope)
1031
+ const beforeSnapshot = await this.snapshotSubscription(sub, scope)
1032
+
1033
+ const { lineChangeSummary } = await this.applyLineChanges(
1034
+ sub,
1035
+ input.lineChanges,
1036
+ scope,
1037
+ null,
1038
+ )
1039
+
1040
+ await this.recomputeSubscriptionAggregatesInPlace(sub, scope)
1041
+
1042
+ await this.em.flush()
1043
+
1044
+ const afterSnapshot = await this.snapshotSubscription(sub, scope)
1045
+
1046
+ const log = this.createChangeLog({
1047
+ subscription: sub,
1048
+ changeType: 'amend',
1049
+ sourceQuoteId: input.sourceQuoteId,
1050
+ sourceOrderId: input.sourceOrderId,
1051
+ performedByUserId: input.performedByUserId,
1052
+ beforeSnapshot,
1053
+ afterSnapshot,
1054
+ lineChanges: lineChangeSummary,
1055
+ })
1056
+
1057
+ sub.lastChangeLogId = log.id
1058
+ await this.em.flush()
1059
+
1060
+ return { changeLog: log, subscription: sub }
1061
+ }
1062
+
1063
+ async applyRenewal(input: ApplyRenewalInput, scope: TenantScope): Promise<ApplyArcResult> {
1064
+ const idempotent = await this.findIdempotentChangeLog(
1065
+ input.sourceOrderId,
1066
+ input.subscriptionId,
1067
+ scope,
1068
+ )
1069
+ if (idempotent) {
1070
+ const sub = await this.requireSubscription(input.subscriptionId, scope)
1071
+ return { changeLog: idempotent, subscription: sub }
1072
+ }
1073
+
1074
+ const sub = await this.requireSubscription(input.subscriptionId, scope)
1075
+ const beforeSnapshot = await this.snapshotSubscription(sub, scope)
1076
+ const oldTermStart = sub.currentTermStart
1077
+ const oldTermEnd = sub.currentTermEnd
1078
+ const oldTermMonths = sub.termMonths
1079
+
1080
+ const newStart = toDate(input.term.newTermStart)
1081
+ const newEnd = toDate(input.term.newTermEnd)
1082
+ sub.currentTermStart = newStart
1083
+ sub.currentTermEnd = newEnd
1084
+ if (input.term.newTermMonths != null) sub.termMonths = input.term.newTermMonths
1085
+
1086
+ const { lineChangeSummary } = await this.applyLineChanges(
1087
+ sub,
1088
+ input.lineChanges,
1089
+ scope,
1090
+ null,
1091
+ )
1092
+
1093
+ await this.recomputeSubscriptionAggregatesInPlace(sub, scope)
1094
+
1095
+ await this.em.flush()
1096
+
1097
+ const afterSnapshot = await this.snapshotSubscription(sub, scope)
1098
+
1099
+ const log = this.createChangeLog({
1100
+ subscription: sub,
1101
+ changeType: 'renew',
1102
+ sourceQuoteId: input.sourceQuoteId,
1103
+ sourceOrderId: input.sourceOrderId,
1104
+ performedByUserId: input.performedByUserId,
1105
+ beforeSnapshot,
1106
+ afterSnapshot,
1107
+ lineChanges: lineChangeSummary,
1108
+ termChange: {
1109
+ oldTermStart: toIsoOrNull(oldTermStart),
1110
+ newTermStart: newStart.toISOString(),
1111
+ oldTermEnd: toIsoOrNull(oldTermEnd),
1112
+ newTermEnd: newEnd.toISOString(),
1113
+ oldTermMonths: oldTermMonths ?? null,
1114
+ newTermMonths: input.term.newTermMonths ?? oldTermMonths ?? null,
1115
+ },
1116
+ })
1117
+
1118
+ sub.lastChangeLogId = log.id
1119
+ await this.em.flush()
1120
+
1121
+ return { changeLog: log, subscription: sub }
1122
+ }
1123
+
1124
+ async applyMergeRenewal(
1125
+ input: ApplyMergeRenewalInput,
1126
+ scope: TenantScope,
1127
+ ): Promise<ApplyMergeRenewalResult> {
1128
+ if (input.sourceIds.length < 2) {
1129
+ throw { status: 400, error: 'Merge requires at least two source subscriptions' }
1130
+ }
1131
+
1132
+ // Idempotency: if every source already has a merge-source ChangeLog row
1133
+ // for this order, the merge has been applied — re-fetch and return.
1134
+ const existingSourceLogs = await this.em.find(CpqSubscriptionChangeLog, {
1135
+ organizationId: scope.organizationId,
1136
+ tenantId: scope.tenantId,
1137
+ deletedAt: null,
1138
+ sourceOrderId: input.sourceOrderId,
1139
+ subscriptionId: { $in: input.sourceIds },
1140
+ changeType: 'merge-source',
1141
+ })
1142
+ if (existingSourceLogs.length === input.sourceIds.length) {
1143
+ const mergeResult = await this.em.findOne(CpqSubscriptionChangeLog, {
1144
+ organizationId: scope.organizationId,
1145
+ tenantId: scope.tenantId,
1146
+ deletedAt: null,
1147
+ sourceOrderId: input.sourceOrderId,
1148
+ changeType: 'merge-result',
1149
+ })
1150
+ if (mergeResult) {
1151
+ const m = await this.requireSubscription(mergeResult.subscriptionId, scope)
1152
+ return {
1153
+ mergedSubscription: m,
1154
+ mergeResultChangeLog: mergeResult,
1155
+ sourceChangeLogs: existingSourceLogs,
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ // Validate sources: same customer/currency/billingCycle, all in {active, suspended}.
1161
+ const sources = await this.em.find(CpqInventorySubscription, {
1162
+ id: { $in: input.sourceIds },
1163
+ organizationId: scope.organizationId,
1164
+ tenantId: scope.tenantId,
1165
+ deletedAt: null,
1166
+ })
1167
+ if (sources.length !== input.sourceIds.length) {
1168
+ throw { status: 404, error: 'One or more source subscriptions not found' }
1169
+ }
1170
+ const customers = new Set(sources.map((s) => s.customerId))
1171
+ const currencies = new Set(sources.map((s) => s.currencyCode))
1172
+ const cycles = new Set(sources.map((s) => s.billingCycle))
1173
+ if (customers.size > 1 || currencies.size > 1 || cycles.size > 1) {
1174
+ throw {
1175
+ status: 400,
1176
+ error: 'Merge sources must share customer, currency, and billing cycle',
1177
+ }
1178
+ }
1179
+ for (const s of sources) {
1180
+ if (s.status !== 'active' && s.status !== 'suspended') {
1181
+ throw {
1182
+ status: 409,
1183
+ error: `Source subscription ${s.id} is in '${s.status}' status — must be active or suspended to merge`,
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ const customerId = sources[0].customerId
1189
+ const currencyCode = sources[0].currencyCode
1190
+ const billingCycle = sources[0].billingCycle
1191
+
1192
+ const beforeSnapshots = new Map<string, Record<string, unknown>>()
1193
+ for (const s of sources) {
1194
+ beforeSnapshots.set(s.id, await this.snapshotSubscription(s, scope))
1195
+ }
1196
+
1197
+ // Create the new merge sub M.
1198
+ const newTermStart = toDate(input.term.newTermStart)
1199
+ const newTermEnd = toDate(input.term.newTermEnd)
1200
+ const code =
1201
+ input.mergeMeta.newSubCode ?? (await this.generateSubscriptionCode(scope))
1202
+ const name = input.mergeMeta.newSubName ?? `Merged contract`
1203
+
1204
+ // Pre-generate id so child rows referenced before flush (carry-over items
1205
+ // FK to subscriptionId, asset migration FK update) don't depend on the
1206
+ // intervening flush() to populate it. Matches the createSubscription pattern.
1207
+ const newSubId = crypto.randomUUID()
1208
+ const m = this.em.create(CpqInventorySubscription, {
1209
+ id: newSubId,
1210
+ organizationId: scope.organizationId,
1211
+ tenantId: scope.tenantId,
1212
+ customerId,
1213
+ code,
1214
+ name,
1215
+ status: 'active',
1216
+ billingCycle,
1217
+ currencyCode,
1218
+ mrcAmount: '0',
1219
+ nrcAmount: '0',
1220
+ currentTermStart: newTermStart,
1221
+ currentTermEnd: newTermEnd,
1222
+ termMonths: input.term.newTermMonths ?? null,
1223
+ autoRenew: true,
1224
+ activatedAt: new Date(),
1225
+ sourceQuoteId: input.sourceQuoteId,
1226
+ sourceOrderId: input.sourceOrderId,
1227
+ })
1228
+ this.em.persist(m)
1229
+ await this.em.flush()
1230
+
1231
+ // Carry-over phase: clone every source's active items onto M.
1232
+ // Build sourceItemId → newItemIdOnM map for line resolution.
1233
+ const sourceItemToNewItem = new Map<string, string>()
1234
+
1235
+ for (const source of sources) {
1236
+ const activeItems = await this.em.find(CpqInventorySubscriptionItem, {
1237
+ subscriptionId: source.id,
1238
+ organizationId: scope.organizationId,
1239
+ tenantId: scope.tenantId,
1240
+ deletedAt: null,
1241
+ status: { $nin: ['terminated', 'expired', 'superseded'] },
1242
+ })
1243
+ for (const item of activeItems) {
1244
+ // Pre-generate the cloned item id — `defaultRaw: 'gen_random_uuid()'`
1245
+ // only fires at INSERT, so without this `cloned.id` is undefined right
1246
+ // after em.create(). Then `sourceItemToNewItem.set(item.id, cloned.id)`
1247
+ // would store undefined and the line-change translation below would
1248
+ // fall back to the source-sub item id, which doesn't exist on the new
1249
+ // merged sub M → applyLineChanges throws 404 at activation.
1250
+ const clonedId = crypto.randomUUID()
1251
+ const cloned = this.em.create(CpqInventorySubscriptionItem, {
1252
+ id: clonedId,
1253
+ organizationId: scope.organizationId,
1254
+ tenantId: scope.tenantId,
1255
+ subscriptionId: m.id,
1256
+ customerId,
1257
+ parentItemId: null,
1258
+ productId: item.productId ?? null,
1259
+ offeringId: item.offeringId ?? null,
1260
+ specId: item.specId ?? null,
1261
+ name: item.name,
1262
+ status: 'active',
1263
+ configuration: { ...item.configuration },
1264
+ charges: item.charges ? [...item.charges] : null,
1265
+ mrcAmount: item.mrcAmount,
1266
+ nrcAmount: item.nrcAmount,
1267
+ currencyCode: item.currencyCode,
1268
+ quantity: item.quantity,
1269
+ capacityTotal: item.capacityTotal,
1270
+ capacityUsed: item.capacityUsed,
1271
+ capacityUnit: item.capacityUnit,
1272
+ // Carry-over marker: sourceQuoteLineId stays null so the operator can
1273
+ // distinguish from operator-add lines later.
1274
+ sourceQuoteLineId: null,
1275
+ metadata: item.metadata,
1276
+ sortOrder: item.sortOrder,
1277
+ })
1278
+ this.em.persist(cloned)
1279
+ sourceItemToNewItem.set(item.id, clonedId)
1280
+ }
1281
+ }
1282
+ await this.em.flush()
1283
+
1284
+ // Migrate assets: every source's assets switch FK to M (and to the new
1285
+ // item id mapped from the original source item id).
1286
+ for (const source of sources) {
1287
+ const assets = await this.em.find(CpqInventoryAsset, {
1288
+ subscriptionId: source.id,
1289
+ organizationId: scope.organizationId,
1290
+ tenantId: scope.tenantId,
1291
+ deletedAt: null,
1292
+ status: { $nin: ['returned', 'cancelled'] },
1293
+ })
1294
+ for (const asset of assets) {
1295
+ asset.subscriptionId = m.id
1296
+ if (asset.subscriptionItemId) {
1297
+ const newItemId = sourceItemToNewItem.get(asset.subscriptionItemId)
1298
+ if (newItemId) asset.subscriptionItemId = newItemId
1299
+ }
1300
+ }
1301
+ }
1302
+ await this.em.flush()
1303
+
1304
+ // Operator-edit phase: apply lineChanges on M's combined item set.
1305
+ // For cancel/modify lines, sourceSubscriptionItemId references an item on
1306
+ // one of the source subs — translate to the corresponding new item on M.
1307
+ const translatedChanges: ArcLineChange[] = input.lineChanges.map((lc) => {
1308
+ if (
1309
+ (lc.action === 'cancel' || lc.action === 'modify') &&
1310
+ lc.sourceSubscriptionItemId
1311
+ ) {
1312
+ const newItemId = sourceItemToNewItem.get(lc.sourceSubscriptionItemId)
1313
+ return {
1314
+ ...lc,
1315
+ sourceSubscriptionItemId: newItemId ?? lc.sourceSubscriptionItemId,
1316
+ }
1317
+ }
1318
+ return lc
1319
+ })
1320
+
1321
+ const { lineChangeSummary } = await this.applyLineChanges(
1322
+ m,
1323
+ translatedChanges,
1324
+ scope,
1325
+ null,
1326
+ )
1327
+
1328
+ await this.recomputeSubscriptionAggregatesInPlace(m, scope)
1329
+ await this.em.flush()
1330
+
1331
+ const afterSnapshot = await this.snapshotSubscription(m, scope)
1332
+
1333
+ // Retire each source: status='superseded', mergedIntoSubscriptionId=M.id,
1334
+ // items cascaded to 'superseded'.
1335
+ const sourceLogs: CpqSubscriptionChangeLog[] = []
1336
+ const now = new Date()
1337
+ for (const source of sources) {
1338
+ const items = await this.em.find(CpqInventorySubscriptionItem, {
1339
+ subscriptionId: source.id,
1340
+ organizationId: scope.organizationId,
1341
+ tenantId: scope.tenantId,
1342
+ deletedAt: null,
1343
+ status: { $nin: ['terminated', 'expired', 'superseded'] },
1344
+ })
1345
+ for (const item of items) {
1346
+ item.status = 'superseded'
1347
+ }
1348
+ source.status = 'superseded'
1349
+ source.mergedIntoSubscriptionId = m.id
1350
+ source.terminatedAt = now
1351
+ // Recompute aggregates: source has no active items left.
1352
+ source.mrcAmount = '0'
1353
+
1354
+ const sourceLog = this.createChangeLog({
1355
+ subscription: source,
1356
+ changeType: 'merge-source',
1357
+ sourceQuoteId: input.sourceQuoteId,
1358
+ sourceOrderId: input.sourceOrderId,
1359
+ performedByUserId: input.performedByUserId,
1360
+ beforeSnapshot: beforeSnapshots.get(source.id) ?? null,
1361
+ // afterSnapshot intentionally null — terminal state IS the meaningful "after".
1362
+ afterSnapshot: null,
1363
+ lineChanges: null,
1364
+ mergedIntoSubscriptionId: m.id,
1365
+ })
1366
+ source.lastChangeLogId = sourceLog.id
1367
+ sourceLogs.push(sourceLog)
1368
+ }
1369
+
1370
+ const mergeResultLog = this.createChangeLog({
1371
+ subscription: m,
1372
+ changeType: 'merge-result',
1373
+ sourceQuoteId: input.sourceQuoteId,
1374
+ sourceOrderId: input.sourceOrderId,
1375
+ performedByUserId: input.performedByUserId,
1376
+ beforeSnapshot: null, // M is brand new — no "before" state.
1377
+ afterSnapshot,
1378
+ lineChanges: lineChangeSummary,
1379
+ termChange: {
1380
+ oldTermStart: null,
1381
+ newTermStart: newTermStart.toISOString(),
1382
+ oldTermEnd: null,
1383
+ newTermEnd: newTermEnd.toISOString(),
1384
+ oldTermMonths: null,
1385
+ newTermMonths: input.term.newTermMonths ?? null,
1386
+ },
1387
+ mergedFromSubscriptionIds: input.sourceIds,
1388
+ })
1389
+ m.lastChangeLogId = mergeResultLog.id
1390
+
1391
+ await this.em.flush()
1392
+
1393
+ return {
1394
+ mergedSubscription: m,
1395
+ mergeResultChangeLog: mergeResultLog,
1396
+ sourceChangeLogs: sourceLogs,
1397
+ }
1398
+ }
1399
+
1400
+ async applyCancel(input: ApplyCancelInput, scope: TenantScope): Promise<ApplyArcResult> {
1401
+ const idempotent = await this.findIdempotentChangeLog(
1402
+ input.sourceOrderId,
1403
+ input.subscriptionId,
1404
+ scope,
1405
+ )
1406
+ if (idempotent) {
1407
+ const sub = await this.requireSubscription(input.subscriptionId, scope)
1408
+ return { changeLog: idempotent, subscription: sub }
1409
+ }
1410
+
1411
+ const sub = await this.requireSubscription(input.subscriptionId, scope)
1412
+ if (sub.status !== 'active' && sub.status !== 'suspended') {
1413
+ throw {
1414
+ status: 409,
1415
+ error: `Cannot cancel subscription in '${sub.status}' status`,
1416
+ }
1417
+ }
1418
+ const beforeSnapshot = await this.snapshotSubscription(sub, scope)
1419
+
1420
+ // Reuse the existing cascade. transitionSubscriptionStatus flushes; that's
1421
+ // fine — we'll snapshot again afterwards.
1422
+ await this.transitionSubscriptionStatus(sub.id, 'terminated', scope)
1423
+
1424
+ const reload = await this.requireSubscription(input.subscriptionId, scope)
1425
+ const afterSnapshot = await this.snapshotSubscription(reload, scope)
1426
+
1427
+ const log = this.createChangeLog({
1428
+ subscription: reload,
1429
+ changeType: 'cancel',
1430
+ sourceQuoteId: input.sourceQuoteId,
1431
+ sourceOrderId: input.sourceOrderId,
1432
+ performedByUserId: input.performedByUserId,
1433
+ beforeSnapshot,
1434
+ afterSnapshot,
1435
+ lineChanges: null,
1436
+ reasonCode: input.reasonCode ?? null,
1437
+ reasonText: input.reasonText ?? null,
1438
+ etfAmount: input.etfAmount != null ? String(input.etfAmount) : null,
1439
+ etfCurrency: input.etfCurrency ?? null,
1440
+ })
1441
+
1442
+ reload.lastChangeLogId = log.id
1443
+ await this.em.flush()
1444
+
1445
+ return { changeLog: log, subscription: reload }
1446
+ }
1447
+
1448
+ // ═══════════════════════════════════════════════════════════════
1449
+ // Private helpers
1450
+ // ═══════════════════════════════════════════════════════════════
1451
+
1452
+ private async recomputeSubscriptionAggregates(subscriptionId: string, scope: TenantScope) {
1453
+ const items = await this.em.find(CpqInventorySubscriptionItem, {
1454
+ subscriptionId,
1455
+ organizationId: scope.organizationId,
1456
+ tenantId: scope.tenantId,
1457
+ deletedAt: null,
1458
+ })
1459
+
1460
+ const mrcSum = items.reduce((s, i) => s + Number(i.mrcAmount), 0)
1461
+ const nrcSum = items.reduce((s, i) => s + Number(i.nrcAmount), 0)
1462
+
1463
+ const sub = await this.em.findOne(CpqInventorySubscription, {
1464
+ id: subscriptionId,
1465
+ organizationId: scope.organizationId,
1466
+ tenantId: scope.tenantId,
1467
+ })
1468
+ if (sub) {
1469
+ sub.mrcAmount = String(mrcSum)
1470
+ sub.nrcAmount = String(nrcSum)
1471
+ await this.em.flush()
1472
+ }
1473
+ }
1474
+
1475
+ // ─── ARC private helpers (XD-250) ────────────────────────────────
1476
+
1477
+ private async requireSubscription(
1478
+ id: string,
1479
+ scope: TenantScope,
1480
+ ): Promise<CpqInventorySubscription> {
1481
+ const sub = await this.em.findOne(CpqInventorySubscription, {
1482
+ id,
1483
+ organizationId: scope.organizationId,
1484
+ tenantId: scope.tenantId,
1485
+ deletedAt: null,
1486
+ })
1487
+ if (!sub) throw { status: 404, error: 'Subscription not found' }
1488
+ return sub
1489
+ }
1490
+
1491
+ private async findIdempotentChangeLog(
1492
+ sourceOrderId: string | null | undefined,
1493
+ subscriptionId: string,
1494
+ scope: TenantScope,
1495
+ ): Promise<CpqSubscriptionChangeLog | null> {
1496
+ if (!sourceOrderId) return null
1497
+ return this.em.findOne(CpqSubscriptionChangeLog, {
1498
+ organizationId: scope.organizationId,
1499
+ tenantId: scope.tenantId,
1500
+ deletedAt: null,
1501
+ sourceOrderId,
1502
+ subscriptionId,
1503
+ })
1504
+ }
1505
+
1506
+ private async snapshotSubscription(
1507
+ sub: CpqInventorySubscription,
1508
+ scope: TenantScope,
1509
+ ): Promise<Record<string, unknown>> {
1510
+ // `?? []` is defensive: in unit tests with a partially-mocked EM the
1511
+ // `find` call may return undefined. Mikro at runtime always returns an
1512
+ // array, so this only matters for harness code.
1513
+ const items = (await this.em.find(CpqInventorySubscriptionItem, {
1514
+ subscriptionId: sub.id,
1515
+ organizationId: scope.organizationId,
1516
+ tenantId: scope.tenantId,
1517
+ deletedAt: null,
1518
+ })) ?? []
1519
+ const assets = (await this.em.find(CpqInventoryAsset, {
1520
+ subscriptionId: sub.id,
1521
+ organizationId: scope.organizationId,
1522
+ tenantId: scope.tenantId,
1523
+ deletedAt: null,
1524
+ })) ?? []
1525
+ return {
1526
+ id: sub.id,
1527
+ code: sub.code,
1528
+ name: sub.name,
1529
+ status: sub.status,
1530
+ billingCycle: sub.billingCycle,
1531
+ currencyCode: sub.currencyCode,
1532
+ mrcAmount: sub.mrcAmount,
1533
+ nrcAmount: sub.nrcAmount,
1534
+ currentTermStart: toIsoOrNull(sub.currentTermStart),
1535
+ currentTermEnd: toIsoOrNull(sub.currentTermEnd),
1536
+ termMonths: sub.termMonths ?? null,
1537
+ version: sub.version,
1538
+ items: items.map((i) => ({
1539
+ id: i.id,
1540
+ productId: i.productId,
1541
+ offeringId: i.offeringId,
1542
+ specId: i.specId,
1543
+ name: i.name,
1544
+ status: i.status,
1545
+ configuration: i.configuration,
1546
+ mrcAmount: i.mrcAmount,
1547
+ nrcAmount: i.nrcAmount,
1548
+ currencyCode: i.currencyCode,
1549
+ quantity: i.quantity,
1550
+ sourceQuoteLineId: i.sourceQuoteLineId,
1551
+ })),
1552
+ assets: assets.map((a) => ({
1553
+ id: a.id,
1554
+ code: a.code,
1555
+ name: a.name,
1556
+ status: a.status,
1557
+ subscriptionItemId: a.subscriptionItemId,
1558
+ productId: a.productId,
1559
+ })),
1560
+ }
1561
+ }
1562
+
1563
+ /**
1564
+ * Apply ARC line changes to a subscription's item set in place.
1565
+ * Returns a per-line summary ready to write into ChangeLog.line_changes.
1566
+ */
1567
+ private async applyLineChanges(
1568
+ sub: CpqInventorySubscription,
1569
+ lineChanges: ArcLineChange[],
1570
+ scope: TenantScope,
1571
+ _ignored: null,
1572
+ ): Promise<{ lineChangeSummary: Array<Record<string, unknown>> }> {
1573
+ void _ignored
1574
+
1575
+ const summary: Array<Record<string, unknown>> = []
1576
+ if (lineChanges.length === 0) return { lineChangeSummary: summary }
1577
+
1578
+ // Pre-load assetizable specs for new items to decide whether to materialize
1579
+ // assets on add lines.
1580
+ const specIdsForAdd = new Set<string>()
1581
+ for (const lc of lineChanges) {
1582
+ if (lc.action === 'add' && lc.itemInput?.specId) {
1583
+ specIdsForAdd.add(lc.itemInput.specId)
1584
+ }
1585
+ }
1586
+ const assetizableSpecs = new Map<string, boolean>()
1587
+ if (specIdsForAdd.size > 0) {
1588
+ const specs = await this.em.find(CpqProductSpecification, {
1589
+ id: { $in: Array.from(specIdsForAdd) },
1590
+ organizationId: scope.organizationId,
1591
+ tenantId: scope.tenantId,
1592
+ deletedAt: null,
1593
+ })
1594
+ for (const s of specs) assetizableSpecs.set(s.id, s.isAssetizable === true)
1595
+ }
1596
+
1597
+ // XD-250 ARC: build quoteLineId → resulting subscriptionItemId map up-front
1598
+ // so `add` lines can resolve their parentItemId regardless of the order
1599
+ // their parent appears in `lineChanges`. For 'modify' the parent already
1600
+ // exists (its sourceSubscriptionItemId is the answer); for sibling 'add'
1601
+ // lines we pre-generate the new item id.
1602
+ const quoteLineToFinalItemId = new Map<string, string>()
1603
+ const preGeneratedAddIds = new Map<string, string>()
1604
+ for (const lc of lineChanges) {
1605
+ if (!lc.quoteLineId) continue
1606
+ if (lc.action === 'add') {
1607
+ const id = crypto.randomUUID()
1608
+ preGeneratedAddIds.set(lc.quoteLineId, id)
1609
+ quoteLineToFinalItemId.set(lc.quoteLineId, id)
1610
+ } else if (lc.action === 'modify' && lc.sourceSubscriptionItemId) {
1611
+ quoteLineToFinalItemId.set(lc.quoteLineId, lc.sourceSubscriptionItemId)
1612
+ }
1613
+ }
1614
+
1615
+ for (const lc of lineChanges) {
1616
+ if (lc.action === 'add') {
1617
+ if (!lc.itemInput) {
1618
+ throw { status: 400, error: 'add line is missing itemInput' }
1619
+ }
1620
+ // Pre-generated id — `defaultRaw: 'gen_random_uuid()'` only fires at
1621
+ // INSERT, so without this `item.id` is undefined between em.create()
1622
+ // and em.flush(). That breaks: (a) the asset row that references
1623
+ // `subscriptionItemId: item.id`, (b) the change-log `itemId` (which
1624
+ // the UI joins against the after-snapshot to resolve names), and
1625
+ // (c) the parentItemId resolution for sibling `add` children.
1626
+ const newItemId = lc.quoteLineId
1627
+ ? preGeneratedAddIds.get(lc.quoteLineId) ?? crypto.randomUUID()
1628
+ : crypto.randomUUID()
1629
+ const parentItemId =
1630
+ lc.itemInput.parentItemId ??
1631
+ (lc.parentQuoteLineId
1632
+ ? quoteLineToFinalItemId.get(lc.parentQuoteLineId) ?? null
1633
+ : null)
1634
+ const item = this.em.create(CpqInventorySubscriptionItem, {
1635
+ id: newItemId,
1636
+ organizationId: scope.organizationId,
1637
+ tenantId: scope.tenantId,
1638
+ subscriptionId: sub.id,
1639
+ customerId: sub.customerId,
1640
+ parentItemId,
1641
+ productId: lc.itemInput.productId ?? null,
1642
+ offeringId: lc.itemInput.offeringId ?? null,
1643
+ specId: lc.itemInput.specId ?? null,
1644
+ name: lc.itemInput.name,
1645
+ status: 'active',
1646
+ configuration: lc.itemInput.configuration ?? {},
1647
+ charges: lc.itemInput.charges ?? null,
1648
+ mrcAmount: String(lc.itemInput.mrcAmount ?? 0),
1649
+ nrcAmount: String(lc.itemInput.nrcAmount ?? 0),
1650
+ currencyCode: lc.itemInput.currencyCode ?? sub.currencyCode,
1651
+ quantity: lc.itemInput.quantity ?? 1,
1652
+ capacityTotal:
1653
+ lc.itemInput.capacityTotal != null ? String(lc.itemInput.capacityTotal) : null,
1654
+ capacityUsed: null,
1655
+ capacityUnit: lc.itemInput.capacityUnit ?? null,
1656
+ sourceQuoteLineId: lc.quoteLineId ?? lc.itemInput.sourceQuoteLineId ?? null,
1657
+ metadata: lc.itemInput.metadata ?? null,
1658
+ sortOrder: lc.itemInput.sortOrder ?? 0,
1659
+ })
1660
+ this.em.persist(item)
1661
+
1662
+ // Materialize a pending asset only when the spec is assetizable.
1663
+ if (lc.itemInput.specId && assetizableSpecs.get(lc.itemInput.specId)) {
1664
+ const code = await this.generateAssetCode(scope)
1665
+ const asset = this.em.create(CpqInventoryAsset, {
1666
+ organizationId: scope.organizationId,
1667
+ tenantId: scope.tenantId,
1668
+ customerId: sub.customerId,
1669
+ subscriptionId: sub.id,
1670
+ subscriptionItemId: item.id,
1671
+ productId: lc.itemInput.productId ?? null,
1672
+ offeringId: lc.itemInput.offeringId ?? null,
1673
+ specId: lc.itemInput.specId,
1674
+ code,
1675
+ name: lc.itemInput.name,
1676
+ status: 'pending',
1677
+ assetType: 'one_time',
1678
+ currencyCode: lc.itemInput.currencyCode ?? sub.currencyCode,
1679
+ purchasePrice: String(lc.itemInput.nrcAmount ?? 0),
1680
+ quantity: lc.itemInput.quantity ?? 1,
1681
+ configuration: lc.itemInput.configuration ?? {},
1682
+ charges: lc.itemInput.charges ?? null,
1683
+ sourceOrderId: null,
1684
+ sourceQuoteLineId: lc.quoteLineId ?? null,
1685
+ })
1686
+ this.em.persist(asset)
1687
+ }
1688
+
1689
+ summary.push({
1690
+ action: 'add',
1691
+ itemId: item.id,
1692
+ quoteLineId: lc.quoteLineId ?? null,
1693
+ mrcAmount: Number(item.mrcAmount),
1694
+ nrcAmount: Number(item.nrcAmount),
1695
+ quantity: item.quantity,
1696
+ })
1697
+ } else if (lc.action === 'cancel') {
1698
+ if (!lc.sourceSubscriptionItemId) {
1699
+ throw { status: 400, error: 'cancel line is missing sourceSubscriptionItemId' }
1700
+ }
1701
+ const item = await this.em.findOne(CpqInventorySubscriptionItem, {
1702
+ id: lc.sourceSubscriptionItemId,
1703
+ subscriptionId: sub.id,
1704
+ organizationId: scope.organizationId,
1705
+ tenantId: scope.tenantId,
1706
+ deletedAt: null,
1707
+ })
1708
+ if (!item) {
1709
+ throw {
1710
+ status: 404,
1711
+ error: `Subscription item ${lc.sourceSubscriptionItemId} not found on subscription ${sub.id}`,
1712
+ }
1713
+ }
1714
+ item.status = 'terminated'
1715
+
1716
+ // Cancel any active assets attached to this item.
1717
+ const assets = await this.em.find(CpqInventoryAsset, {
1718
+ subscriptionItemId: item.id,
1719
+ organizationId: scope.organizationId,
1720
+ tenantId: scope.tenantId,
1721
+ deletedAt: null,
1722
+ status: { $nin: ['returned', 'cancelled'] },
1723
+ })
1724
+ for (const asset of assets) asset.status = 'cancelled'
1725
+
1726
+ summary.push({
1727
+ action: 'cancel',
1728
+ itemId: item.id,
1729
+ quoteLineId: lc.quoteLineId ?? null,
1730
+ mrcDelta: -Number(item.mrcAmount),
1731
+ })
1732
+ } else if (lc.action === 'modify') {
1733
+ if (!lc.sourceSubscriptionItemId) {
1734
+ throw { status: 400, error: 'modify line is missing sourceSubscriptionItemId' }
1735
+ }
1736
+ const item = await this.em.findOne(CpqInventorySubscriptionItem, {
1737
+ id: lc.sourceSubscriptionItemId,
1738
+ subscriptionId: sub.id,
1739
+ organizationId: scope.organizationId,
1740
+ tenantId: scope.tenantId,
1741
+ deletedAt: null,
1742
+ })
1743
+ if (!item) {
1744
+ throw {
1745
+ status: 404,
1746
+ error: `Subscription item ${lc.sourceSubscriptionItemId} not found on subscription ${sub.id}`,
1747
+ }
1748
+ }
1749
+ const oldMrc = Number(item.mrcAmount)
1750
+ if (lc.modifyPatch?.configuration !== undefined) {
1751
+ item.configuration = lc.modifyPatch.configuration
1752
+ }
1753
+ if (lc.modifyPatch?.charges !== undefined) {
1754
+ item.charges = lc.modifyPatch.charges
1755
+ }
1756
+ if (lc.modifyPatch?.mrcAmount !== undefined) {
1757
+ item.mrcAmount = String(lc.modifyPatch.mrcAmount)
1758
+ }
1759
+ if (lc.modifyPatch?.nrcAmount !== undefined) {
1760
+ item.nrcAmount = String(lc.modifyPatch.nrcAmount)
1761
+ }
1762
+ if (lc.modifyPatch?.quantity !== undefined) {
1763
+ item.quantity = lc.modifyPatch.quantity
1764
+ }
1765
+ const newMrc = Number(item.mrcAmount)
1766
+ if (newMrc !== oldMrc || lc.modifyPatch?.configuration || lc.modifyPatch?.charges) {
1767
+ summary.push({
1768
+ action: 'modify',
1769
+ itemId: item.id,
1770
+ quoteLineId: lc.quoteLineId ?? null,
1771
+ mrcDelta: newMrc - oldMrc,
1772
+ })
1773
+ }
1774
+ }
1775
+ }
1776
+
1777
+ return { lineChangeSummary: summary }
1778
+ }
1779
+
1780
+ private async recomputeSubscriptionAggregatesInPlace(
1781
+ sub: CpqInventorySubscription,
1782
+ scope: TenantScope,
1783
+ ): Promise<void> {
1784
+ const items = await this.em.find(CpqInventorySubscriptionItem, {
1785
+ subscriptionId: sub.id,
1786
+ organizationId: scope.organizationId,
1787
+ tenantId: scope.tenantId,
1788
+ deletedAt: null,
1789
+ status: { $nin: ['terminated', 'expired', 'superseded'] },
1790
+ })
1791
+ const mrcSum = items.reduce((s, i) => s + Number(i.mrcAmount), 0)
1792
+ const nrcSum = items.reduce((s, i) => s + Number(i.nrcAmount), 0)
1793
+ sub.mrcAmount = String(mrcSum)
1794
+ sub.nrcAmount = String(nrcSum)
1795
+ }
1796
+
1797
+ private createChangeLog(args: {
1798
+ subscription: CpqInventorySubscription
1799
+ changeType: ChangeLogType
1800
+ sourceQuoteId?: string | null
1801
+ sourceOrderId?: string | null
1802
+ performedByUserId?: string | null
1803
+ beforeSnapshot: Record<string, unknown> | null
1804
+ afterSnapshot: Record<string, unknown> | null
1805
+ lineChanges: Array<Record<string, unknown>> | null
1806
+ termChange?: Record<string, unknown> | null
1807
+ mergedIntoSubscriptionId?: string | null
1808
+ mergedFromSubscriptionIds?: string[] | null
1809
+ reasonCode?: string | null
1810
+ reasonText?: string | null
1811
+ etfAmount?: string | null
1812
+ etfCurrency?: string | null
1813
+ }): CpqSubscriptionChangeLog {
1814
+ const log = this.em.create(CpqSubscriptionChangeLog, {
1815
+ organizationId: args.subscription.organizationId,
1816
+ tenantId: args.subscription.tenantId,
1817
+ subscriptionId: args.subscription.id,
1818
+ changeType: args.changeType,
1819
+ sourceQuoteId: args.sourceQuoteId ?? null,
1820
+ sourceOrderId: args.sourceOrderId ?? null,
1821
+ performedByUserId: args.performedByUserId ?? null,
1822
+ effectiveAt: new Date(),
1823
+ beforeSnapshot: args.beforeSnapshot,
1824
+ afterSnapshot: args.afterSnapshot,
1825
+ lineChanges: args.lineChanges,
1826
+ termChange: args.termChange ?? null,
1827
+ mergedIntoSubscriptionId: args.mergedIntoSubscriptionId ?? null,
1828
+ mergedFromSubscriptionIds: args.mergedFromSubscriptionIds ?? null,
1829
+ reasonCode: args.reasonCode ?? null,
1830
+ reasonText: args.reasonText ?? null,
1831
+ etfAmount: args.etfAmount ?? null,
1832
+ etfCurrency: args.etfCurrency ?? null,
1833
+ })
1834
+ this.em.persist(log)
1835
+ return log
1836
+ }
1837
+
1838
+ private buildItemTree(items: CpqInventorySubscriptionItem[]): SubscriptionItemResultNode[] {
1839
+ const nodeMap = new Map<string, SubscriptionItemResultNode>()
1840
+
1841
+ for (const item of items) {
1842
+ nodeMap.set(item.id, {
1843
+ id: item.id,
1844
+ subscriptionId: item.subscriptionId,
1845
+ parentItemId: item.parentItemId ?? null,
1846
+ productId: item.productId ?? null,
1847
+ offeringId: item.offeringId ?? null,
1848
+ specId: item.specId ?? null,
1849
+ name: item.name,
1850
+ status: item.status,
1851
+ configuration: item.configuration ?? {},
1852
+ charges: item.charges ?? null,
1853
+ mrcAmount: Number(item.mrcAmount),
1854
+ nrcAmount: Number(item.nrcAmount),
1855
+ currencyCode: item.currencyCode,
1856
+ quantity: item.quantity,
1857
+ capacityTotal: item.capacityTotal != null ? Number(item.capacityTotal) : null,
1858
+ capacityUsed: item.capacityUsed != null ? Number(item.capacityUsed) : null,
1859
+ capacityUnit: item.capacityUnit ?? null,
1860
+ metadata: item.metadata ?? null,
1861
+ sortOrder: item.sortOrder,
1862
+ children: [],
1863
+ })
1864
+ }
1865
+
1866
+ const roots: SubscriptionItemResultNode[] = []
1867
+
1868
+ for (const node of nodeMap.values()) {
1869
+ if (node.parentItemId && nodeMap.has(node.parentItemId)) {
1870
+ nodeMap.get(node.parentItemId)!.children.push(node)
1871
+ } else {
1872
+ roots.push(node)
1873
+ }
1874
+ }
1875
+
1876
+ return roots
1877
+ }
1878
+
1879
+ private async generateSubscriptionCode(scope: TenantScope): Promise<string> {
1880
+ const year = new Date().getFullYear()
1881
+ const count = await this.em.count(CpqInventorySubscription, {
1882
+ organizationId: scope.organizationId,
1883
+ tenantId: scope.tenantId,
1884
+ })
1885
+ const seq = String(count + 1).padStart(5, '0')
1886
+ return `SUB-${year}-${seq}`
1887
+ }
1888
+
1889
+ private async generateAssetCode(scope: TenantScope): Promise<string> {
1890
+ const year = new Date().getFullYear()
1891
+ const count = await this.em.count(CpqInventoryAsset, {
1892
+ organizationId: scope.organizationId,
1893
+ tenantId: scope.tenantId,
1894
+ })
1895
+ const seq = String(count + 1).padStart(5, '0')
1896
+ return `AST-${year}-${seq}`
1897
+ }
1898
+
1899
+ private async subscriptionIdsForProduct(productId: string, scope: TenantScope): Promise<string[]> {
1900
+ const items = await this.em.find(
1901
+ CpqInventorySubscriptionItem,
1902
+ {
1903
+ organizationId: scope.organizationId,
1904
+ tenantId: scope.tenantId,
1905
+ deletedAt: null,
1906
+ productId,
1907
+ },
1908
+ { fields: ['subscriptionId'] as const },
1909
+ )
1910
+ return [...new Set(items.map((i) => i.subscriptionId))]
1911
+ }
1912
+ }
1913
+
1914
+ // ─── Module-private helpers ─────────────────────────────────────
1915
+
1916
+ function toDate(value: Date | string): Date {
1917
+ return value instanceof Date ? value : new Date(value)
1918
+ }
1919
+
1920
+ /**
1921
+ * MikroORM `@Property({ type: 'date' })` columns hydrate as strings on
1922
+ * Postgres (YYYY-MM-DD) — calling `.toISOString()` on them throws. This
1923
+ * helper accepts either form.
1924
+ */
1925
+ function toIsoOrNull(value: Date | string | null | undefined): string | null {
1926
+ if (value == null) return null
1927
+ if (value instanceof Date) return value.toISOString()
1928
+ return String(value)
1929
+ }