@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,2638 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
3
+ import { CustomerEntity } from '@open-mercato/core/modules/customers/data/entities'
4
+ import type { SalesDocumentNumberGenerator } from '@open-mercato/core/modules/sales/services/salesDocumentNumberGenerator'
5
+ import {
6
+ CpqQuoteConfiguration,
7
+ CpqQuoteLineConfiguration,
8
+ CpqQuoteTargetSubscription,
9
+ CpqInventorySubscription,
10
+ CpqInventorySubscriptionItem,
11
+ CpqOrderConfiguration,
12
+ CpqProductOffering,
13
+ CpqProductSpecification,
14
+ } from '../data/entities'
15
+ import type { DefaultCpqPricingService } from './cpqPricingService'
16
+ import type { DefaultCpqValidationService } from './cpqValidationService'
17
+ import type { DefaultCpqProductService } from './cpqProductService'
18
+ import type {
19
+ CreateQuoteInput,
20
+ QuoteItemInput,
21
+ QuoteResult,
22
+ QuoteLineResult,
23
+ PricingSummary,
24
+ ValidationError,
25
+ ResolvedCharge,
26
+ TenantScope,
27
+ CpqStatus,
28
+ CpqQuoteType,
29
+ MergeAction,
30
+ ArcReasonCode,
31
+ ArcLineSource,
32
+ } from './types'
33
+ import {
34
+ ALLOWED_TRANSITIONS,
35
+ TERMINAL_STATUSES,
36
+ ARC_QUOTE_TYPES,
37
+ getBaseCurrencyCode,
38
+ } from './types'
39
+
40
+ interface QuotingServiceDeps {
41
+ em: EntityManager
42
+ cpqPricingService: DefaultCpqPricingService
43
+ cpqValidationService: DefaultCpqValidationService
44
+ cpqProductService: DefaultCpqProductService
45
+ salesDocumentNumberGenerator: SalesDocumentNumberGenerator
46
+ }
47
+
48
+ export class DefaultCpqQuotingService {
49
+ private em: EntityManager
50
+ private pricingService: DefaultCpqPricingService
51
+ private validationService: DefaultCpqValidationService
52
+ private productService: DefaultCpqProductService
53
+ private numberGenerator: SalesDocumentNumberGenerator
54
+
55
+ constructor(deps: QuotingServiceDeps) {
56
+ this.em = deps.em
57
+ this.pricingService = deps.cpqPricingService
58
+ this.validationService = deps.cpqValidationService
59
+ this.productService = deps.cpqProductService
60
+ this.numberGenerator = deps.salesDocumentNumberGenerator
61
+ }
62
+
63
+ // ─── Create Quote ──────────────────────────────────────────────
64
+
65
+ async createQuote(input: CreateQuoteInput, scope: TenantScope): Promise<QuoteResult> {
66
+ const em = this.em
67
+
68
+ // Validate customer exists (lookup by id + tenantId; organizationId may differ
69
+ // between CPQ scope resolution and how the core customers API resolves it)
70
+ const customer = await em.findOne(CustomerEntity, {
71
+ id: input.customerId,
72
+ tenantId: scope.tenantId,
73
+ }, { populate: ['personProfile', 'companyProfile'] })
74
+
75
+ if (!customer) {
76
+ throw new QuotingError(404, 'Customer not found')
77
+ }
78
+
79
+ // Build customer snapshot
80
+ const customerSnapshot = buildCustomerSnapshot(customer)
81
+
82
+ // Build initial quote context from customer
83
+ const quoteContext: Record<string, string | number | boolean> = {
84
+ ...(input.quoteContext ?? {}),
85
+ }
86
+ if (customer.kind) quoteContext.customer_type = customer.kind
87
+ if (customer.companyProfile?.domain) quoteContext.customer_domain = customer.companyProfile.domain
88
+
89
+ const currencyCode = input.currencyCode ?? await getBaseCurrencyCode(em, scope)
90
+
91
+ let salesQuote: SalesQuote
92
+ let salesQuoteId: string
93
+
94
+ if (input.quoteId) {
95
+ // Link to existing SalesQuote
96
+ const existing = await em.findOne(SalesQuote, {
97
+ id: input.quoteId,
98
+ organizationId: scope.organizationId,
99
+ tenantId: scope.tenantId,
100
+ })
101
+ if (!existing) throw new QuotingError(404, 'Sales quote not found')
102
+
103
+ // Check if CPQ config already exists
104
+ const existingConfig = await em.findOne(CpqQuoteConfiguration, {
105
+ quoteId: input.quoteId,
106
+ organizationId: scope.organizationId,
107
+ tenantId: scope.tenantId,
108
+ deletedAt: null,
109
+ })
110
+ if (existingConfig) {
111
+ // Already initialized — just return the existing config
112
+ const lines = await this.loadLineConfigs(em, existingConfig.id, scope)
113
+ return this.buildQuoteResult(em, existingConfig, existing, lines)
114
+ }
115
+
116
+ salesQuote = existing
117
+ salesQuoteId = existing.id
118
+ } else {
119
+ // Create new SalesQuote — assign UUID upfront so CpqQuoteConfiguration can reference it before flush
120
+ salesQuoteId = crypto.randomUUID()
121
+
122
+ const { number: quoteNumber } = await this.numberGenerator.generate({
123
+ kind: 'quote',
124
+ organizationId: scope.organizationId,
125
+ tenantId: scope.tenantId,
126
+ })
127
+
128
+ salesQuote = em.create(SalesQuote, {
129
+ id: salesQuoteId,
130
+ organizationId: scope.organizationId,
131
+ tenantId: scope.tenantId,
132
+ quoteNumber,
133
+ customerEntityId: input.customerId,
134
+ customerSnapshot,
135
+ currencyCode,
136
+ subtotalNetAmount: '0',
137
+ subtotalGrossAmount: '0',
138
+ discountTotalAmount: '0',
139
+ taxTotalAmount: '0',
140
+ grandTotalNetAmount: '0',
141
+ grandTotalGrossAmount: '0',
142
+ lineItemCount: 0,
143
+ createdAt: new Date(),
144
+ updatedAt: new Date(),
145
+ })
146
+ em.persist(salesQuote)
147
+ }
148
+
149
+ // Create CpqQuoteConfiguration
150
+ const cpqConfig = em.create(CpqQuoteConfiguration, {
151
+ organizationId: scope.organizationId,
152
+ tenantId: scope.tenantId,
153
+ quoteId: salesQuoteId,
154
+ customerId: input.customerId,
155
+ cpqStatus: 'new',
156
+ version: 1,
157
+ currencyCode,
158
+ quoteContext,
159
+ validationResult: { valid: true, errors: [] },
160
+ pricingSummary: emptyPricingSummary(currencyCode),
161
+ })
162
+ em.persist(cpqConfig)
163
+
164
+ await em.flush()
165
+
166
+ return this.buildQuoteResult(em, cpqConfig, salesQuote, [])
167
+ }
168
+
169
+ // ─── Add Quote Item ────────────────────────────────────────────
170
+
171
+ async addQuoteItem(quoteId: string, item: QuoteItemInput, scope: TenantScope): Promise<QuoteResult> {
172
+ const em = this.em
173
+
174
+ const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
175
+ const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
176
+
177
+ if (TERMINAL_STATUSES.includes(cpqConfig.cpqStatus as CpqStatus)) {
178
+ throw new QuotingError(409, `Cannot modify quote in '${cpqConfig.cpqStatus}' status`)
179
+ }
180
+
181
+ // Resolve offering -> spec -> product
182
+ const { offering, spec } = await this.resolveOffering(em, item, scope)
183
+
184
+ // Merge context: quoteContext + designTimeValues + user configuration
185
+ const mergedConfig = {
186
+ ...(cpqConfig.quoteContext ?? {}),
187
+ ...(offering?.designTimeValues ?? {}),
188
+ ...item.configuration,
189
+ }
190
+
191
+ // If termMonths is provided, include it in the merged config for pricing
192
+ if (item.termMonths) {
193
+ mergedConfig.term_months = item.termMonths
194
+ }
195
+
196
+ // Validate configuration
197
+ let validationErrors: ValidationError[] = []
198
+ if (offering) {
199
+ const validation = await this.validationService.validateOfferingConfiguration({
200
+ offeringId: offering.id,
201
+ configuration: item.configuration,
202
+ scope,
203
+ })
204
+ validationErrors = validation.errors
205
+ }
206
+
207
+ // Resolve charges
208
+ const charges = await this.pricingService.resolveProductCharges({
209
+ offeringId: offering?.id,
210
+ productId: item.productId ?? spec?.productId,
211
+ configuration: mergedConfig,
212
+ currencyCode: cpqConfig.currencyCode,
213
+ ...scope,
214
+ })
215
+
216
+ const quantity = item.quantity ?? 1
217
+
218
+ // Compute line totals
219
+ const { nrcTotal, mrcTotal, usageEstimates, usageTotalEstimated } = computeLineTotals(charges, quantity, item.usageEstimates)
220
+
221
+ const isBundleOffering = offering?.offeringType === 'bundle'
222
+ const isConfigured = validationErrors.length === 0 && (charges.length > 0 || isBundleOffering)
223
+
224
+ // Compute end date
225
+ const endDate = computeEndDate(item.startDate, item.termMonths, item.endDate)
226
+
227
+ // Get current line count
228
+ const existingLines = await em.find(SalesQuoteLine, {
229
+ quote: salesQuote,
230
+ organizationId: scope.organizationId,
231
+ tenantId: scope.tenantId,
232
+ deletedAt: null,
233
+ })
234
+ const lineNumber = existingLines.length + 1
235
+
236
+ // Per-instance unit price (NRC + MRC for one instance)
237
+ const perInstanceNrc = charges.filter((c) => c.chargeType === 'nrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
238
+ const perInstanceMrc = charges.filter((c) => c.chargeType === 'mrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
239
+ const unitPriceNet = perInstanceNrc + perInstanceMrc
240
+ const totalNetAmount = unitPriceNet * quantity
241
+
242
+ // Create SalesQuoteLine — assign UUID upfront so CpqQuoteLineConfiguration can reference it
243
+ const quoteLineId = crypto.randomUUID()
244
+ const quoteLine = em.create(SalesQuoteLine, {
245
+ id: quoteLineId,
246
+ quote: salesQuote,
247
+ organizationId: scope.organizationId,
248
+ tenantId: scope.tenantId,
249
+ lineNumber,
250
+ kind: 'product',
251
+ productId: spec?.productId ?? item.productId ?? null,
252
+ name: offering?.name ?? spec?.name ?? 'Configured Item',
253
+ quantity: String(quantity),
254
+ unitPriceNet: String(unitPriceNet),
255
+ unitPriceGross: String(unitPriceNet),
256
+ totalNetAmount: String(totalNetAmount),
257
+ totalGrossAmount: String(totalNetAmount),
258
+ discountAmount: '0',
259
+ discountPercent: '0',
260
+ taxRate: '0',
261
+ taxAmount: '0',
262
+ currencyCode: cpqConfig.currencyCode,
263
+ configuration: {
264
+ offeringId: offering?.id ?? null,
265
+ offeringCode: offering?.code ?? null,
266
+ action: item.action ?? 'add',
267
+ nrcTotal: Number(nrcTotal),
268
+ mrcTotal: Number(mrcTotal),
269
+ parentLineId: item.parentLineId ?? null,
270
+ },
271
+ normalizedQuantity: String(quantity),
272
+ createdAt: new Date(),
273
+ updatedAt: new Date(),
274
+ })
275
+ em.persist(quoteLine)
276
+
277
+ // XD-250 ARC: a new line on an ARC quote must point at one of the
278
+ // attached target subscriptions (validateArcQuote enforces this on
279
+ // submit-for-approval). Auto-inherit when there's exactly one target;
280
+ // for merge-renew (≥2 targets, all 'absorb') leave it null per spec.
281
+ // The operator can override via item.targetSubscriptionId when a quote
282
+ // has multiple non-merge targets.
283
+ const arcTargetSubscriptionId = await resolveArcLineTargetSubscriptionId(
284
+ em,
285
+ cpqConfig,
286
+ scope,
287
+ item.targetSubscriptionId ?? undefined,
288
+ )
289
+
290
+ // Create CpqQuoteLineConfiguration
291
+ const lineConfig = em.create(CpqQuoteLineConfiguration, {
292
+ organizationId: scope.organizationId,
293
+ tenantId: scope.tenantId,
294
+ quoteLineId: quoteLine.id,
295
+ quoteConfigurationId: cpqConfig.id,
296
+ offeringId: offering?.id ?? null,
297
+ productId: spec?.productId ?? item.productId ?? null,
298
+ specId: spec?.id ?? null,
299
+ action: item.action ?? 'add',
300
+ parentLineId: item.parentLineId ?? null,
301
+ startDate: item.startDate ? new Date(item.startDate) : null,
302
+ termMonths: item.termMonths ?? null,
303
+ endDate: endDate ? new Date(endDate) : null,
304
+ configuration: item.configuration,
305
+ quantity,
306
+ charges: charges as unknown as Array<Record<string, unknown>>,
307
+ nrcTotal: String(nrcTotal),
308
+ mrcTotal: String(mrcTotal),
309
+ usageEstimates: usageEstimates ?? null,
310
+ usageTotalEstimated: usageTotalEstimated != null ? String(usageTotalEstimated) : null,
311
+ validationErrors: validationErrors.length > 0 ? validationErrors as unknown as Array<Record<string, unknown>> : null,
312
+ isConfigured,
313
+ targetSubscriptionId: arcTargetSubscriptionId,
314
+ })
315
+ em.persist(lineConfig)
316
+
317
+ await em.flush()
318
+
319
+ // Recalculate quote-level totals
320
+ return this.recalculateInternal(em, cpqConfig, salesQuote, scope, true)
321
+ }
322
+
323
+ // ─── Update Quote Item ─────────────────────────────────────────
324
+
325
+ async updateQuoteItem(
326
+ quoteId: string,
327
+ lineId: string,
328
+ update: { configuration: Record<string, unknown>; quantity?: number; usageEstimates?: Array<{ chargeCode: string; estimatedQuantity: number }>; startDate?: string; termMonths?: number; endDate?: string },
329
+ scope: TenantScope,
330
+ ): Promise<QuoteResult> {
331
+ const em = this.em
332
+
333
+ const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
334
+ const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
335
+
336
+ if (TERMINAL_STATUSES.includes(cpqConfig.cpqStatus as CpqStatus)) {
337
+ throw new QuotingError(409, `Cannot modify quote in '${cpqConfig.cpqStatus}' status`)
338
+ }
339
+
340
+ const lineConfig = await em.findOne(CpqQuoteLineConfiguration, {
341
+ quoteLineId: lineId,
342
+ quoteConfigurationId: cpqConfig.id,
343
+ organizationId: scope.organizationId,
344
+ tenantId: scope.tenantId,
345
+ deletedAt: null,
346
+ })
347
+ if (!lineConfig) throw new QuotingError(404, 'Quote line not found')
348
+
349
+ const quoteLine = await em.findOne(SalesQuoteLine, {
350
+ id: lineId,
351
+ organizationId: scope.organizationId,
352
+ tenantId: scope.tenantId,
353
+ deletedAt: null,
354
+ })
355
+ if (!quoteLine) throw new QuotingError(404, 'Quote line not found')
356
+
357
+ // Load offering for context merge
358
+ let offering: CpqProductOffering | null = null
359
+ if (lineConfig.offeringId) {
360
+ offering = await em.findOne(CpqProductOffering, {
361
+ id: lineConfig.offeringId,
362
+ organizationId: scope.organizationId,
363
+ tenantId: scope.tenantId,
364
+ deletedAt: null,
365
+ })
366
+ }
367
+
368
+ const mergedConfig = {
369
+ ...(cpqConfig.quoteContext ?? {}),
370
+ ...(offering?.designTimeValues ?? {}),
371
+ ...update.configuration,
372
+ }
373
+
374
+ if (update.termMonths) {
375
+ mergedConfig.term_months = update.termMonths
376
+ }
377
+
378
+ // Validate
379
+ let validationErrors: ValidationError[] = []
380
+ if (lineConfig.offeringId) {
381
+ const validation = await this.validationService.validateOfferingConfiguration({
382
+ offeringId: lineConfig.offeringId,
383
+ configuration: update.configuration,
384
+ scope,
385
+ })
386
+ validationErrors = validation.errors
387
+ }
388
+
389
+ // Preserve XD-250 ARC markers across edits — the UI doesn't echo them
390
+ // back in `update.configuration`, so we'd lose `_arcMirroredFromItemId` /
391
+ // `_arcMirroredName` (and downstream "Mirrored from existing subscription
392
+ // item" rendering, plus the recalc / validate paths that key off them).
393
+ const existingCfg = lineConfig.configuration as Record<string, unknown> | null
394
+ const arcMarkers: Record<string, unknown> = {}
395
+ if (existingCfg?._arcMirroredFromItemId !== undefined) {
396
+ arcMarkers._arcMirroredFromItemId = existingCfg._arcMirroredFromItemId
397
+ }
398
+ if (existingCfg?._arcMirroredName !== undefined) {
399
+ arcMarkers._arcMirroredName = existingCfg._arcMirroredName
400
+ }
401
+ update.configuration = { ...update.configuration, ...arcMarkers }
402
+ mergedConfig._arcMirroredFromItemId = arcMarkers._arcMirroredFromItemId
403
+ mergedConfig._arcMirroredName = arcMarkers._arcMirroredName
404
+
405
+ // Re-price — same ARC mirror handling as recalculateInternal: skip the
406
+ // pricing service when the line carries no offering / product (XD-250
407
+ // mirror lines were created free-form). Without this guard, edits like
408
+ // changing quantity wipe the synthesized charges and the line collapses
409
+ // to "incomplete" with mrcTotal=0.
410
+ const isArcMirrored = !!(arcMarkers._arcMirroredFromItemId)
411
+ let charges: ResolvedCharge[]
412
+ if (lineConfig.offeringId || lineConfig.productId) {
413
+ charges = await this.pricingService.resolveProductCharges({
414
+ offeringId: lineConfig.offeringId ?? undefined,
415
+ productId: lineConfig.productId ?? undefined,
416
+ configuration: mergedConfig,
417
+ currencyCode: cpqConfig.currencyCode,
418
+ ...scope,
419
+ })
420
+ } else {
421
+ charges = (lineConfig.charges ?? []) as unknown as ResolvedCharge[]
422
+ }
423
+
424
+ const quantity = update.quantity ?? lineConfig.quantity
425
+ const { nrcTotal, mrcTotal, usageEstimates, usageTotalEstimated } = computeLineTotals(charges, quantity, update.usageEstimates)
426
+ const isBundleOffering = offering?.offeringType === 'bundle'
427
+ const isConfigured =
428
+ validationErrors.length === 0 && (charges.length > 0 || isBundleOffering || isArcMirrored)
429
+
430
+ const endDate = computeEndDate(update.startDate ?? lineConfig.startDate?.toISOString(), update.termMonths ?? lineConfig.termMonths ?? undefined, update.endDate)
431
+
432
+ // Update line config
433
+ em.assign(lineConfig, {
434
+ configuration: update.configuration,
435
+ quantity,
436
+ charges: charges as unknown as Array<Record<string, unknown>>,
437
+ nrcTotal: String(nrcTotal),
438
+ mrcTotal: String(mrcTotal),
439
+ usageEstimates: usageEstimates ?? null,
440
+ usageTotalEstimated: usageTotalEstimated != null ? String(usageTotalEstimated) : null,
441
+ validationErrors: validationErrors.length > 0 ? validationErrors as unknown as Array<Record<string, unknown>> : null,
442
+ isConfigured,
443
+ startDate: update.startDate ? new Date(update.startDate) : lineConfig.startDate,
444
+ termMonths: update.termMonths ?? lineConfig.termMonths,
445
+ endDate: endDate ? new Date(endDate) : lineConfig.endDate,
446
+ })
447
+
448
+ // Update SalesQuoteLine totals
449
+ const perInstanceNrc = charges.filter((c) => c.chargeType === 'nrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
450
+ const perInstanceMrc = charges.filter((c) => c.chargeType === 'mrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
451
+ const unitPriceNet = perInstanceNrc + perInstanceMrc
452
+
453
+ em.assign(quoteLine, {
454
+ quantity: String(quantity),
455
+ unitPriceNet: String(unitPriceNet),
456
+ unitPriceGross: String(unitPriceNet),
457
+ totalNetAmount: String(unitPriceNet * quantity),
458
+ totalGrossAmount: String(unitPriceNet * quantity),
459
+ updatedAt: new Date(),
460
+ })
461
+
462
+ await em.flush()
463
+
464
+ return this.recalculateInternal(em, cpqConfig, salesQuote, scope, true)
465
+ }
466
+
467
+ // ─── Remove Quote Item ─────────────────────────────────────────
468
+
469
+ async removeQuoteItem(quoteId: string, lineId: string, scope: TenantScope): Promise<QuoteResult> {
470
+ const em = this.em
471
+
472
+ const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
473
+ const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
474
+
475
+ if (TERMINAL_STATUSES.includes(cpqConfig.cpqStatus as CpqStatus)) {
476
+ throw new QuotingError(409, `Cannot modify quote in '${cpqConfig.cpqStatus}' status`)
477
+ }
478
+
479
+ // Soft-delete the line and its children
480
+ const lineConfig = await em.findOne(CpqQuoteLineConfiguration, {
481
+ quoteLineId: lineId,
482
+ quoteConfigurationId: cpqConfig.id,
483
+ organizationId: scope.organizationId,
484
+ tenantId: scope.tenantId,
485
+ deletedAt: null,
486
+ })
487
+ if (!lineConfig) throw new QuotingError(404, 'Quote line not found')
488
+
489
+ const now = new Date()
490
+ lineConfig.deletedAt = now
491
+
492
+ // Soft-delete children
493
+ const children = await em.find(CpqQuoteLineConfiguration, {
494
+ parentLineId: lineId,
495
+ quoteConfigurationId: cpqConfig.id,
496
+ organizationId: scope.organizationId,
497
+ tenantId: scope.tenantId,
498
+ deletedAt: null,
499
+ })
500
+ for (const child of children) {
501
+ child.deletedAt = now
502
+ // Also soft-delete the SalesQuoteLine
503
+ const childSalesLine = await em.findOne(SalesQuoteLine, {
504
+ id: child.quoteLineId,
505
+ organizationId: scope.organizationId,
506
+ tenantId: scope.tenantId,
507
+ })
508
+ if (childSalesLine) childSalesLine.deletedAt = now
509
+ }
510
+
511
+ // Soft-delete the SalesQuoteLine
512
+ const salesLine = await em.findOne(SalesQuoteLine, {
513
+ id: lineId,
514
+ organizationId: scope.organizationId,
515
+ tenantId: scope.tenantId,
516
+ })
517
+ if (salesLine) salesLine.deletedAt = now
518
+
519
+ await em.flush()
520
+
521
+ return this.recalculateInternal(em, cpqConfig, salesQuote, scope, true)
522
+ }
523
+
524
+ // ─── Get Quote (read from DB, no recalculation) ────────────────
525
+
526
+ async getQuote(quoteId: string, scope: TenantScope): Promise<QuoteResult> {
527
+ const em = this.em
528
+
529
+ const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
530
+ const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
531
+
532
+ const lineConfigs = await em.find(CpqQuoteLineConfiguration, {
533
+ quoteConfigurationId: cpqConfig.id,
534
+ organizationId: scope.organizationId,
535
+ tenantId: scope.tenantId,
536
+ deletedAt: null,
537
+ })
538
+
539
+ return this.buildQuoteResult(em, cpqConfig, salesQuote, lineConfigs)
540
+ }
541
+
542
+ // ─── Recalculate ───────────────────────────────────────────────
543
+
544
+ async recalculate(quoteId: string, options: { save: boolean }, scope: TenantScope): Promise<QuoteResult> {
545
+ const em = options.save ? this.em.fork() : this.em
546
+
547
+ const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
548
+ const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
549
+
550
+ return this.recalculateInternal(em, cpqConfig, salesQuote, scope, options.save)
551
+ }
552
+
553
+ // ─── Clone Quote ───────────────────────────────────────────────
554
+
555
+ async cloneQuote(quoteId: string, scope: TenantScope): Promise<QuoteResult> {
556
+ const em = this.em
557
+
558
+ const original = await this.loadCpqConfig(em, quoteId, scope)
559
+ const originalQuote = await this.loadSalesQuote(em, original.quoteId, scope)
560
+
561
+ // Generate new quote number
562
+ const { number: quoteNumber } = await this.numberGenerator.generate({
563
+ kind: 'quote',
564
+ organizationId: scope.organizationId,
565
+ tenantId: scope.tenantId,
566
+ })
567
+
568
+ // Create new SalesQuote — assign UUID upfront
569
+ const newSalesQuoteId = crypto.randomUUID()
570
+ const newSalesQuote = em.create(SalesQuote, {
571
+ id: newSalesQuoteId,
572
+ organizationId: scope.organizationId,
573
+ tenantId: scope.tenantId,
574
+ quoteNumber,
575
+ customerEntityId: originalQuote.customerEntityId,
576
+ customerSnapshot: originalQuote.customerSnapshot,
577
+ currencyCode: original.currencyCode,
578
+ subtotalNetAmount: '0',
579
+ subtotalGrossAmount: '0',
580
+ discountTotalAmount: '0',
581
+ taxTotalAmount: '0',
582
+ grandTotalNetAmount: '0',
583
+ grandTotalGrossAmount: '0',
584
+ lineItemCount: 0,
585
+ createdAt: new Date(),
586
+ updatedAt: new Date(),
587
+ })
588
+ em.persist(newSalesQuote)
589
+
590
+ // Create new CpqQuoteConfiguration
591
+ const newCpqConfig = em.create(CpqQuoteConfiguration, {
592
+ organizationId: scope.organizationId,
593
+ tenantId: scope.tenantId,
594
+ quoteId: newSalesQuoteId,
595
+ customerId: original.customerId,
596
+ cpqStatus: 'new',
597
+ version: original.version + 1,
598
+ parentQuoteId: original.id,
599
+ currencyCode: original.currencyCode,
600
+ quoteContext: original.quoteContext ? { ...original.quoteContext } : null,
601
+ validationResult: { valid: true, errors: [] },
602
+ pricingSummary: emptyPricingSummary(original.currencyCode),
603
+ })
604
+ em.persist(newCpqConfig)
605
+
606
+ // Copy all lines
607
+ const originalLines = await em.find(CpqQuoteLineConfiguration, {
608
+ quoteConfigurationId: original.id,
609
+ organizationId: scope.organizationId,
610
+ tenantId: scope.tenantId,
611
+ deletedAt: null,
612
+ })
613
+
614
+ // Map old lineId -> new lineId for parent references
615
+ const lineIdMap = new Map<string, string>()
616
+
617
+ for (const origLine of originalLines) {
618
+ const origSalesLine = await em.findOne(SalesQuoteLine, {
619
+ id: origLine.quoteLineId,
620
+ organizationId: scope.organizationId,
621
+ tenantId: scope.tenantId,
622
+ })
623
+
624
+ const newSalesLineId = crypto.randomUUID()
625
+ const newSalesLine = em.create(SalesQuoteLine, {
626
+ id: newSalesLineId,
627
+ quote: newSalesQuote,
628
+ organizationId: scope.organizationId,
629
+ tenantId: scope.tenantId,
630
+ lineNumber: origSalesLine?.lineNumber ?? 1,
631
+ kind: origSalesLine?.kind ?? 'product',
632
+ productId: origSalesLine?.productId ?? null,
633
+ name: origSalesLine?.name ?? null,
634
+ quantity: origSalesLine?.quantity ?? '1',
635
+ unitPriceNet: origSalesLine?.unitPriceNet ?? '0',
636
+ unitPriceGross: origSalesLine?.unitPriceGross ?? '0',
637
+ totalNetAmount: origSalesLine?.totalNetAmount ?? '0',
638
+ totalGrossAmount: origSalesLine?.totalGrossAmount ?? '0',
639
+ discountAmount: origSalesLine?.discountAmount ?? '0',
640
+ discountPercent: origSalesLine?.discountPercent ?? '0',
641
+ taxRate: origSalesLine?.taxRate ?? '0',
642
+ taxAmount: origSalesLine?.taxAmount ?? '0',
643
+ currencyCode: origSalesLine?.currencyCode ?? original.currencyCode,
644
+ configuration: origSalesLine?.configuration ?? null,
645
+ normalizedQuantity: origSalesLine?.normalizedQuantity ?? '0',
646
+ createdAt: new Date(),
647
+ updatedAt: new Date(),
648
+ })
649
+ em.persist(newSalesLine)
650
+
651
+ lineIdMap.set(origLine.quoteLineId, newSalesLine.id)
652
+
653
+ const newLineConfig = em.create(CpqQuoteLineConfiguration, {
654
+ organizationId: scope.organizationId,
655
+ tenantId: scope.tenantId,
656
+ quoteLineId: newSalesLine.id,
657
+ quoteConfigurationId: newCpqConfig.id,
658
+ offeringId: origLine.offeringId,
659
+ productId: origLine.productId,
660
+ specId: origLine.specId,
661
+ action: origLine.action,
662
+ parentLineId: null, // Will be resolved after all lines are created
663
+ startDate: origLine.startDate,
664
+ termMonths: origLine.termMonths,
665
+ endDate: origLine.endDate,
666
+ configuration: { ...origLine.configuration },
667
+ quantity: origLine.quantity,
668
+ charges: origLine.charges ? [...origLine.charges] : null,
669
+ nrcTotal: origLine.nrcTotal,
670
+ mrcTotal: origLine.mrcTotal,
671
+ usageEstimates: origLine.usageEstimates,
672
+ usageTotalEstimated: origLine.usageTotalEstimated,
673
+ validationErrors: origLine.validationErrors,
674
+ isConfigured: origLine.isConfigured,
675
+ })
676
+ em.persist(newLineConfig)
677
+ }
678
+
679
+ // Resolve parent line references
680
+ const newLines = await em.find(CpqQuoteLineConfiguration, {
681
+ quoteConfigurationId: newCpqConfig.id,
682
+ organizationId: scope.organizationId,
683
+ tenantId: scope.tenantId,
684
+ })
685
+ for (const origLine of originalLines) {
686
+ if (origLine.parentLineId) {
687
+ const newParentLineId = lineIdMap.get(origLine.parentLineId)
688
+ const newLine = newLines.find((l) => l.quoteLineId === lineIdMap.get(origLine.quoteLineId))
689
+ if (newLine && newParentLineId) {
690
+ newLine.parentLineId = newParentLineId
691
+ }
692
+ }
693
+ }
694
+
695
+ await em.flush()
696
+
697
+ // Recalculate the new quote at current rates
698
+ return this.recalculateInternal(em, newCpqConfig, newSalesQuote, scope, true)
699
+ }
700
+
701
+ // ─── Transition Status ─────────────────────────────────────────
702
+
703
+ async transitionStatus(quoteId: string, targetStatus: string, scope: TenantScope): Promise<QuoteResult> {
704
+ const em = this.em
705
+
706
+ const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
707
+ const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
708
+
709
+ const currentStatus = cpqConfig.cpqStatus as CpqStatus
710
+ const allowed = ALLOWED_TRANSITIONS[currentStatus] ?? []
711
+
712
+ if (!allowed.includes(targetStatus as CpqStatus)) {
713
+ throw new QuotingError(409, `Cannot transition from '${currentStatus}' to '${targetStatus}'`)
714
+ }
715
+
716
+ // XD-250 ARC: at submit-for-approval (and the no-approval shortcuts that
717
+ // bypass it), validate ARC invariants. Spec § Services /
718
+ // validateArcQuote: "Called automatically on the submit-for-approval
719
+ // transition." `quoteType ?? 'new'` defends against pre-existing rows
720
+ // (and against test stubs) that may not carry the column — we only run
721
+ // the ARC validator when the quote is explicitly an ARC type.
722
+ const isSubmitForApproval =
723
+ targetStatus === 'in_approval' ||
724
+ targetStatus === 'pre_approved' ||
725
+ targetStatus === 'with_customer'
726
+ const quoteType = cpqConfig.quoteType ?? 'new'
727
+ if (isSubmitForApproval && quoteType !== 'new') {
728
+ const validation = await this.validateArcQuote(quoteId, scope)
729
+ if (!validation.ok) {
730
+ throw new QuotingError(
731
+ 422,
732
+ `ARC quote validation failed: ${validation.errors.join('; ')}`,
733
+ )
734
+ }
735
+ }
736
+
737
+ // The previous service-level guard required `with_customer` to come
738
+ // from `approved` / `pre_approved` / `ready`. That conflicted with the
739
+ // product decision to let operators jump between any pair of statuses,
740
+ // so it was removed. ARC validation above remains as the real guard
741
+ // against semantic inconsistency on the submit-for-approval path.
742
+
743
+ cpqConfig.cpqStatus = targetStatus
744
+ await em.flush()
745
+
746
+ const lines = await this.loadLineConfigs(em, cpqConfig.id, scope)
747
+ return this.buildQuoteResult(em, cpqConfig, salesQuote, lines)
748
+ }
749
+
750
+ // ─── Internal: Recalculate ─────────────────────────────────────
751
+
752
+ private async recalculateInternal(
753
+ em: EntityManager,
754
+ cpqConfig: CpqQuoteConfiguration,
755
+ salesQuote: SalesQuote,
756
+ scope: TenantScope,
757
+ save: boolean,
758
+ ): Promise<QuoteResult> {
759
+ const lineConfigs = await em.find(CpqQuoteLineConfiguration, {
760
+ quoteConfigurationId: cpqConfig.id,
761
+ organizationId: scope.organizationId,
762
+ tenantId: scope.tenantId,
763
+ deletedAt: null,
764
+ })
765
+
766
+ let allValid = true
767
+ const allErrors: ValidationError[] = []
768
+
769
+ // Collect freshly computed data for each line (used for response regardless of save)
770
+ const computedLines: Array<{
771
+ lineConfig: CpqQuoteLineConfiguration
772
+ charges: ResolvedCharge[]
773
+ nrcTotal: number
774
+ mrcTotal: number
775
+ usageEstimates: Array<{ chargeCode: string; estimatedQuantity: number; estimatedTotal: number }> | null
776
+ usageTotalEstimated: number | null
777
+ validationErrors: ValidationError[]
778
+ isConfigured: boolean
779
+ }> = []
780
+
781
+ for (const lineConfig of lineConfigs) {
782
+ let offering: CpqProductOffering | null = null
783
+ if (lineConfig.offeringId) {
784
+ offering = await em.findOne(CpqProductOffering, {
785
+ id: lineConfig.offeringId,
786
+ organizationId: scope.organizationId,
787
+ tenantId: scope.tenantId,
788
+ deletedAt: null,
789
+ })
790
+ }
791
+
792
+ const mergedConfig = {
793
+ ...(cpqConfig.quoteContext ?? {}),
794
+ ...(offering?.designTimeValues ?? {}),
795
+ ...lineConfig.configuration,
796
+ }
797
+
798
+ if (lineConfig.termMonths) {
799
+ mergedConfig.term_months = lineConfig.termMonths
800
+ }
801
+
802
+ let validationErrors: ValidationError[] = []
803
+ if (lineConfig.offeringId) {
804
+ const validation = await this.validationService.validateOfferingConfiguration({
805
+ offeringId: lineConfig.offeringId,
806
+ configuration: lineConfig.configuration,
807
+ scope,
808
+ })
809
+ validationErrors = validation.errors
810
+ }
811
+
812
+ // ARC-mirrored lines (XD-250) carry no offering/product because the
813
+ // source subscription item was created free-form (e.g. seeded with just
814
+ // a name + mrcAmount). Pricing service has nothing to resolve from, so
815
+ // skip the call and keep the synthesized / copied charges already on
816
+ // lineConfig — otherwise quantity edits would wipe them and flip the
817
+ // line to "incomplete".
818
+ const cfgRecord = lineConfig.configuration as Record<string, unknown> | null
819
+ const isArcMirrored = !!(cfgRecord?._arcMirroredFromItemId)
820
+ let charges: ResolvedCharge[]
821
+ if (lineConfig.offeringId || lineConfig.productId) {
822
+ charges = await this.pricingService.resolveProductCharges({
823
+ offeringId: lineConfig.offeringId ?? undefined,
824
+ productId: lineConfig.productId ?? undefined,
825
+ configuration: mergedConfig,
826
+ currencyCode: cpqConfig.currencyCode,
827
+ ...scope,
828
+ })
829
+ } else {
830
+ charges = (lineConfig.charges ?? []) as unknown as ResolvedCharge[]
831
+ }
832
+
833
+ const { nrcTotal, mrcTotal, usageEstimates, usageTotalEstimated } = computeLineTotals(
834
+ charges,
835
+ lineConfig.quantity,
836
+ lineConfig.usageEstimates?.map((e) => ({ chargeCode: e.chargeCode, estimatedQuantity: e.estimatedQuantity })),
837
+ )
838
+
839
+ const isBundleOffering = offering?.offeringType === 'bundle'
840
+ const isConfigured =
841
+ validationErrors.length === 0 && (charges.length > 0 || isBundleOffering || isArcMirrored)
842
+ if (!isConfigured) allValid = false
843
+ allErrors.push(...validationErrors)
844
+
845
+ computedLines.push({ lineConfig, charges, nrcTotal, mrcTotal, usageEstimates, usageTotalEstimated, validationErrors, isConfigured })
846
+
847
+ if (save) {
848
+ em.assign(lineConfig, {
849
+ charges: charges as unknown as Array<Record<string, unknown>>,
850
+ nrcTotal: String(nrcTotal),
851
+ mrcTotal: String(mrcTotal),
852
+ usageEstimates: usageEstimates ?? null,
853
+ usageTotalEstimated: usageTotalEstimated != null ? String(usageTotalEstimated) : null,
854
+ validationErrors: validationErrors.length > 0 ? validationErrors as unknown as Array<Record<string, unknown>> : null,
855
+ isConfigured,
856
+ })
857
+
858
+ const salesLine = await em.findOne(SalesQuoteLine, {
859
+ id: lineConfig.quoteLineId,
860
+ organizationId: scope.organizationId,
861
+ tenantId: scope.tenantId,
862
+ })
863
+ if (salesLine) {
864
+ const perInstanceNrc = charges.filter((c) => c.chargeType === 'nrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
865
+ const perInstanceMrc = charges.filter((c) => c.chargeType === 'mrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
866
+ const unitPriceNet = perInstanceNrc + perInstanceMrc
867
+ em.assign(salesLine, {
868
+ quantity: String(lineConfig.quantity),
869
+ unitPriceNet: String(unitPriceNet),
870
+ unitPriceGross: String(unitPriceNet),
871
+ totalNetAmount: String(unitPriceNet * lineConfig.quantity),
872
+ totalGrossAmount: String(unitPriceNet * lineConfig.quantity),
873
+ updatedAt: new Date(),
874
+ })
875
+ }
876
+ }
877
+ }
878
+
879
+ const relationshipErrors = await this.validationService.validateRelationships({
880
+ lines: lineConfigs.map((l) => ({
881
+ lineId: l.quoteLineId,
882
+ specId: l.specId ?? null,
883
+ parentLineId: l.parentLineId ?? null,
884
+ })),
885
+ scope,
886
+ })
887
+ allErrors.push(...relationshipErrors)
888
+ if (relationshipErrors.length > 0) allValid = false
889
+
890
+ let cpqStatus: CpqStatus
891
+ if (lineConfigs.length === 0) {
892
+ cpqStatus = 'new'
893
+ } else if (!allValid) {
894
+ cpqStatus = 'incomplete'
895
+ } else {
896
+ cpqStatus = 'ready'
897
+ }
898
+
899
+ const autoManagedStatuses: CpqStatus[] = ['new', 'incomplete', 'ready']
900
+ if (autoManagedStatuses.includes(cpqConfig.cpqStatus as CpqStatus)) {
901
+ cpqConfig.cpqStatus = cpqStatus
902
+ }
903
+
904
+ // Build pricing summary from freshly computed data (not from stale entity fields)
905
+ const pricingSummary = computePricingSummaryFromComputed(computedLines, cpqConfig.currencyCode)
906
+
907
+ const validationResult = { valid: allValid, errors: allErrors }
908
+
909
+ if (save) {
910
+ cpqConfig.validationResult = validationResult
911
+ cpqConfig.pricingSummary = pricingSummary as unknown as Record<string, unknown>
912
+
913
+ const grandTotal = pricingSummary.nrcTotal + pricingSummary.mrcTotal
914
+ em.assign(salesQuote, {
915
+ subtotalNetAmount: String(grandTotal),
916
+ subtotalGrossAmount: String(grandTotal),
917
+ grandTotalNetAmount: String(grandTotal),
918
+ grandTotalGrossAmount: String(grandTotal),
919
+ lineItemCount: lineConfigs.length,
920
+ updatedAt: new Date(),
921
+ })
922
+
923
+ await em.flush()
924
+ }
925
+
926
+ return this.buildQuoteResultFromComputed(em, cpqConfig, salesQuote, computedLines, validationResult, pricingSummary)
927
+ }
928
+
929
+ // ─── Helpers ───────────────────────────────────────────────────
930
+
931
+ private async loadCpqConfig(em: EntityManager, quoteId: string, scope: TenantScope): Promise<CpqQuoteConfiguration> {
932
+ // Try finding by CpqQuoteConfiguration.id first, then by quoteId
933
+ let config = await em.findOne(CpqQuoteConfiguration, {
934
+ id: quoteId,
935
+ organizationId: scope.organizationId,
936
+ tenantId: scope.tenantId,
937
+ deletedAt: null,
938
+ })
939
+ if (!config) {
940
+ config = await em.findOne(CpqQuoteConfiguration, {
941
+ quoteId,
942
+ organizationId: scope.organizationId,
943
+ tenantId: scope.tenantId,
944
+ deletedAt: null,
945
+ })
946
+ }
947
+ if (!config) throw new QuotingError(404, 'Quote not found')
948
+ return config
949
+ }
950
+
951
+ private async loadSalesQuote(em: EntityManager, quoteId: string, scope: TenantScope): Promise<SalesQuote> {
952
+ const quote = await em.findOne(SalesQuote, {
953
+ id: quoteId,
954
+ organizationId: scope.organizationId,
955
+ tenantId: scope.tenantId,
956
+ })
957
+ if (!quote) throw new QuotingError(404, 'Sales quote not found')
958
+ return quote
959
+ }
960
+
961
+ private async loadLineConfigs(em: EntityManager, cpqConfigId: string, scope: TenantScope): Promise<CpqQuoteLineConfiguration[]> {
962
+ return em.find(CpqQuoteLineConfiguration, {
963
+ quoteConfigurationId: cpqConfigId,
964
+ organizationId: scope.organizationId,
965
+ tenantId: scope.tenantId,
966
+ deletedAt: null,
967
+ })
968
+ }
969
+
970
+ private async resolveOffering(
971
+ em: EntityManager,
972
+ item: QuoteItemInput,
973
+ scope: TenantScope,
974
+ ): Promise<{ offering: CpqProductOffering | null; spec: CpqProductSpecification | null }> {
975
+ const dbScope = { organizationId: scope.organizationId, tenantId: scope.tenantId, deletedAt: null }
976
+
977
+ if (item.offeringId) {
978
+ const offering = await em.findOne(CpqProductOffering, { id: item.offeringId, ...dbScope })
979
+ if (!offering) throw new QuotingError(404, `Offering ${item.offeringId} not found`)
980
+ const spec = await em.findOne(CpqProductSpecification, { id: offering.specId, ...dbScope })
981
+ return { offering, spec }
982
+ }
983
+
984
+ if (item.productId) {
985
+ const spec = await em.findOne(CpqProductSpecification, { productId: item.productId, ...dbScope })
986
+ return { offering: null, spec }
987
+ }
988
+
989
+ throw new QuotingError(400, 'Either offeringId or productId is required')
990
+ }
991
+
992
+ private async buildQuoteResult(
993
+ em: EntityManager,
994
+ cpqConfig: CpqQuoteConfiguration,
995
+ salesQuote: SalesQuote,
996
+ lineConfigs: CpqQuoteLineConfiguration[],
997
+ validationResult?: { valid: boolean; errors: ValidationError[] },
998
+ pricingSummary?: PricingSummary,
999
+ ): Promise<QuoteResult> {
1000
+ const lines: QuoteLineResult[] = []
1001
+ const arcSourceMap = await loadArcSourceMap(em, lineConfigs, cpqConfig)
1002
+
1003
+ for (const lc of lineConfigs) {
1004
+ let offeringName: string | null = null
1005
+ let offeringType: string | null = null
1006
+ if (lc.offeringId) {
1007
+ const offering = await em.findOne(CpqProductOffering, {
1008
+ id: lc.offeringId,
1009
+ organizationId: cpqConfig.organizationId,
1010
+ tenantId: cpqConfig.tenantId,
1011
+ })
1012
+ if (offering) {
1013
+ offeringName = offering.name
1014
+ offeringType = offering.offeringType ?? 'simple'
1015
+ }
1016
+ }
1017
+ if (!offeringName) {
1018
+ const cfg = lc.configuration as Record<string, unknown> | null
1019
+ offeringName =
1020
+ (cfg?._arcMirroredName as string | undefined) ??
1021
+ (cfg?.offeringName as string | undefined) ??
1022
+ 'Configured Item'
1023
+ }
1024
+
1025
+ lines.push({
1026
+ lineId: lc.quoteLineId,
1027
+ offeringId: lc.offeringId ?? null,
1028
+ offeringName,
1029
+ offeringType,
1030
+ productId: lc.productId ?? null,
1031
+ action: lc.action,
1032
+ parentLineId: lc.parentLineId ?? null,
1033
+ quantity: lc.quantity,
1034
+ configuration: lc.configuration ?? {},
1035
+ startDate: lc.startDate?.toISOString() ?? null,
1036
+ termMonths: lc.termMonths ?? null,
1037
+ endDate: lc.endDate?.toISOString() ?? null,
1038
+ nrcTotal: Number(lc.nrcTotal),
1039
+ mrcTotal: Number(lc.mrcTotal),
1040
+ usageEstimates: lc.usageEstimates ?? null,
1041
+ usageTotalEstimated: lc.usageTotalEstimated != null ? Number(lc.usageTotalEstimated) : null,
1042
+ charges: (lc.charges ?? []) as unknown as ResolvedCharge[],
1043
+ isConfigured: lc.isConfigured,
1044
+ validationErrors: lc.validationErrors as unknown as ValidationError[] ?? null,
1045
+ arcSource: pickArcSource(lc, arcSourceMap),
1046
+ targetSubscriptionId: lc.targetSubscriptionId ?? null,
1047
+ })
1048
+ }
1049
+
1050
+ const finalValidation = validationResult ?? (cpqConfig.validationResult as { valid: boolean; errors: ValidationError[] }) ?? { valid: true, errors: [] }
1051
+ const finalPricing = pricingSummary ?? computePricingSummary(lineConfigs, cpqConfig.currencyCode)
1052
+
1053
+ const { customerName, convertedOrderId } = await resolveQuoteSideData(em, cpqConfig)
1054
+
1055
+ return {
1056
+ id: cpqConfig.id,
1057
+ quoteId: cpqConfig.quoteId,
1058
+ quoteNumber: salesQuote.quoteNumber ?? '',
1059
+ customerId: cpqConfig.customerId,
1060
+ customerName,
1061
+ cpqStatus: cpqConfig.cpqStatus,
1062
+ version: cpqConfig.version,
1063
+ parentQuoteId: cpqConfig.parentQuoteId ?? null,
1064
+ currencyCode: cpqConfig.currencyCode,
1065
+ convertedOrderId,
1066
+ validationResult: finalValidation,
1067
+ pricingSummary: finalPricing,
1068
+ lines,
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * Build quote result from freshly computed line data instead of (potentially stale) entity fields.
1074
+ * Used by recalculateInternal to ensure both save:true and save:false return current prices.
1075
+ */
1076
+ private async buildQuoteResultFromComputed(
1077
+ em: EntityManager,
1078
+ cpqConfig: CpqQuoteConfiguration,
1079
+ salesQuote: SalesQuote,
1080
+ computedLines: Array<{
1081
+ lineConfig: CpqQuoteLineConfiguration
1082
+ charges: ResolvedCharge[]
1083
+ nrcTotal: number
1084
+ mrcTotal: number
1085
+ usageEstimates: Array<{ chargeCode: string; estimatedQuantity: number; estimatedTotal: number }> | null
1086
+ usageTotalEstimated: number | null
1087
+ validationErrors: ValidationError[]
1088
+ isConfigured: boolean
1089
+ }>,
1090
+ validationResult: { valid: boolean; errors: ValidationError[] },
1091
+ pricingSummary: PricingSummary,
1092
+ ): Promise<QuoteResult> {
1093
+ const lines: QuoteLineResult[] = []
1094
+ const arcSourceMap = await loadArcSourceMap(
1095
+ em,
1096
+ computedLines.map((c) => c.lineConfig),
1097
+ cpqConfig,
1098
+ )
1099
+
1100
+ for (const { lineConfig: lc, charges, nrcTotal, mrcTotal, usageEstimates, usageTotalEstimated, validationErrors, isConfigured } of computedLines) {
1101
+ let offeringName: string | null = null
1102
+ let offeringType: string | null = null
1103
+ if (lc.offeringId) {
1104
+ const offering = await em.findOne(CpqProductOffering, {
1105
+ id: lc.offeringId,
1106
+ organizationId: cpqConfig.organizationId,
1107
+ tenantId: cpqConfig.tenantId,
1108
+ })
1109
+ if (offering) {
1110
+ offeringName = offering.name
1111
+ offeringType = offering.offeringType ?? 'simple'
1112
+ }
1113
+ }
1114
+ if (!offeringName) {
1115
+ const cfg = lc.configuration as Record<string, unknown> | null
1116
+ offeringName =
1117
+ (cfg?._arcMirroredName as string | undefined) ??
1118
+ (cfg?.offeringName as string | undefined) ??
1119
+ 'Configured Item'
1120
+ }
1121
+
1122
+ lines.push({
1123
+ lineId: lc.quoteLineId,
1124
+ offeringId: lc.offeringId ?? null,
1125
+ offeringName,
1126
+ offeringType,
1127
+ productId: lc.productId ?? null,
1128
+ action: lc.action,
1129
+ parentLineId: lc.parentLineId ?? null,
1130
+ quantity: lc.quantity,
1131
+ configuration: lc.configuration ?? {},
1132
+ startDate: lc.startDate?.toISOString() ?? null,
1133
+ termMonths: lc.termMonths ?? null,
1134
+ endDate: lc.endDate?.toISOString() ?? null,
1135
+ nrcTotal,
1136
+ mrcTotal,
1137
+ usageEstimates,
1138
+ usageTotalEstimated,
1139
+ charges,
1140
+ isConfigured,
1141
+ validationErrors: validationErrors.length > 0 ? validationErrors : null,
1142
+ arcSource: pickArcSource(lc, arcSourceMap),
1143
+ targetSubscriptionId: lc.targetSubscriptionId ?? null,
1144
+ })
1145
+ }
1146
+
1147
+ const { customerName, convertedOrderId } = await resolveQuoteSideData(em, cpqConfig)
1148
+
1149
+ return {
1150
+ id: cpqConfig.id,
1151
+ quoteId: cpqConfig.quoteId,
1152
+ quoteNumber: salesQuote.quoteNumber ?? '',
1153
+ customerId: cpqConfig.customerId,
1154
+ customerName,
1155
+ cpqStatus: cpqConfig.cpqStatus,
1156
+ version: cpqConfig.version,
1157
+ parentQuoteId: cpqConfig.parentQuoteId ?? null,
1158
+ currencyCode: cpqConfig.currencyCode,
1159
+ convertedOrderId,
1160
+ validationResult,
1161
+ pricingSummary,
1162
+ lines,
1163
+ }
1164
+ }
1165
+ }
1166
+
1167
+ // ─── Pure helpers ──────────────────────────────────────────────────
1168
+
1169
+ /**
1170
+ * Load the bits of `QuoteResult` that don't live on `CpqQuoteConfiguration`
1171
+ * itself: the customer display name (for header / drawer labels) and any
1172
+ * existing converted order id (locks editing in the UI).
1173
+ */
1174
+ async function resolveQuoteSideData(
1175
+ em: EntityManager,
1176
+ cpqConfig: CpqQuoteConfiguration,
1177
+ ): Promise<{ customerName: string | null; convertedOrderId: string | null }> {
1178
+ const scope = {
1179
+ organizationId: cpqConfig.organizationId,
1180
+ tenantId: cpqConfig.tenantId,
1181
+ }
1182
+
1183
+ const [customer, existingOrder] = await Promise.all([
1184
+ em.findOne(CustomerEntity, { id: cpqConfig.customerId, ...scope }),
1185
+ em.findOne(
1186
+ CpqOrderConfiguration,
1187
+ { sourceQuoteId: cpqConfig.id, ...scope, deletedAt: null },
1188
+ { fields: ['id', 'orderId'] as never },
1189
+ ),
1190
+ ])
1191
+
1192
+ return {
1193
+ customerName: customer?.displayName ?? null,
1194
+ convertedOrderId: existingOrder?.orderId ?? null,
1195
+ }
1196
+ }
1197
+
1198
+ function buildCustomerSnapshot(customer: CustomerEntity): Record<string, unknown> {
1199
+ return {
1200
+ customer: {
1201
+ id: customer.id,
1202
+ kind: customer.kind,
1203
+ displayName: customer.displayName,
1204
+ primaryEmail: customer.primaryEmail ?? null,
1205
+ primaryPhone: customer.primaryPhone ?? null,
1206
+ personProfile: customer.personProfile
1207
+ ? {
1208
+ id: customer.personProfile.id,
1209
+ firstName: customer.personProfile.firstName ?? null,
1210
+ lastName: customer.personProfile.lastName ?? null,
1211
+ preferredName: customer.personProfile.preferredName ?? null,
1212
+ }
1213
+ : null,
1214
+ companyProfile: customer.companyProfile
1215
+ ? {
1216
+ id: customer.companyProfile.id,
1217
+ legalName: customer.companyProfile.legalName ?? null,
1218
+ brandName: customer.companyProfile.brandName ?? null,
1219
+ domain: customer.companyProfile.domain ?? null,
1220
+ websiteUrl: customer.companyProfile.websiteUrl ?? null,
1221
+ }
1222
+ : null,
1223
+ },
1224
+ contact: null,
1225
+ }
1226
+ }
1227
+
1228
+ function computeLineTotals(
1229
+ charges: ResolvedCharge[],
1230
+ quantity: number,
1231
+ usageEstimateInputs?: Array<{ chargeCode: string; estimatedQuantity: number }>,
1232
+ ) {
1233
+ const nrcPerInstance = charges.filter((c) => c.chargeType === 'nrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
1234
+ const mrcPerInstance = charges.filter((c) => c.chargeType === 'mrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
1235
+
1236
+ const nrcTotal = nrcPerInstance * quantity
1237
+ const mrcTotal = mrcPerInstance * quantity
1238
+
1239
+ let usageEstimates: Array<{ chargeCode: string; estimatedQuantity: number; estimatedTotal: number }> | null = null
1240
+ let usageTotalEstimated: number | null = null
1241
+
1242
+ if (usageEstimateInputs && usageEstimateInputs.length > 0) {
1243
+ const usageCharges = charges.filter((c) => c.chargeType === 'usage')
1244
+ usageEstimates = usageEstimateInputs.map((est) => {
1245
+ const charge = usageCharges.find((c) => c.chargeCode === est.chargeCode)
1246
+ const estimatedTotal = (charge?.unitPrice ?? 0) * est.estimatedQuantity
1247
+ return {
1248
+ chargeCode: est.chargeCode,
1249
+ estimatedQuantity: est.estimatedQuantity,
1250
+ estimatedTotal,
1251
+ }
1252
+ })
1253
+ usageTotalEstimated = usageEstimates.reduce((s, e) => s + e.estimatedTotal, 0) * quantity
1254
+ }
1255
+
1256
+ return { nrcTotal, mrcTotal, usageEstimates, usageTotalEstimated }
1257
+ }
1258
+
1259
+ function computeEndDate(startDate?: string | null, termMonths?: number, endDate?: string | null): string | null {
1260
+ if (endDate) return endDate
1261
+ if (startDate && termMonths) {
1262
+ const start = new Date(startDate)
1263
+ start.setMonth(start.getMonth() + termMonths)
1264
+ return start.toISOString().split('T')[0]
1265
+ }
1266
+ return null
1267
+ }
1268
+
1269
+ function aggregateAdjustmentTotals(allCharges: ResolvedCharge[]): { discountTotal: number; surchargeTotal: number } {
1270
+ let discountTotal = 0
1271
+ let surchargeTotal = 0
1272
+ for (const charge of allCharges) {
1273
+ if (!charge.adjustments) continue
1274
+ for (const adj of charge.adjustments) {
1275
+ if (adj.delta < 0) {
1276
+ discountTotal += Math.abs(adj.delta) * (charge.quantity ?? 1)
1277
+ } else if (adj.delta > 0) {
1278
+ surchargeTotal += adj.delta * (charge.quantity ?? 1)
1279
+ }
1280
+ }
1281
+ }
1282
+ return { discountTotal, surchargeTotal }
1283
+ }
1284
+
1285
+ function computePricingSummary(lineConfigs: CpqQuoteLineConfiguration[], currencyCode: string): PricingSummary {
1286
+ let nrcTotal = 0
1287
+ let mrcTotal = 0
1288
+ let usageTotalEstimated: number | null = null
1289
+ const usageCharges: PricingSummary['usageCharges'] = []
1290
+ const allCharges: ResolvedCharge[] = []
1291
+
1292
+ for (const lc of lineConfigs) {
1293
+ nrcTotal += Number(lc.nrcTotal)
1294
+ mrcTotal += Number(lc.mrcTotal)
1295
+ if (lc.usageTotalEstimated != null) {
1296
+ usageTotalEstimated = (usageTotalEstimated ?? 0) + Number(lc.usageTotalEstimated)
1297
+ }
1298
+
1299
+ const charges = (lc.charges ?? []) as unknown as ResolvedCharge[]
1300
+ allCharges.push(...charges)
1301
+ for (const charge of charges) {
1302
+ if (charge.chargeType === 'usage') {
1303
+ usageCharges.push({
1304
+ chargeCode: charge.chargeCode,
1305
+ chargeName: charge.chargeName,
1306
+ unitPrice: charge.unitPrice,
1307
+ note: charge.note ?? null,
1308
+ })
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ const { discountTotal, surchargeTotal } = aggregateAdjustmentTotals(allCharges)
1314
+
1315
+ return {
1316
+ nrcTotal,
1317
+ mrcTotal,
1318
+ usageCharges,
1319
+ usageTotalEstimated,
1320
+ discountTotal,
1321
+ surchargeTotal,
1322
+ currencyCode,
1323
+ }
1324
+ }
1325
+
1326
+ function computePricingSummaryFromComputed(
1327
+ computedLines: Array<{
1328
+ lineConfig: CpqQuoteLineConfiguration
1329
+ charges: ResolvedCharge[]
1330
+ nrcTotal: number
1331
+ mrcTotal: number
1332
+ usageEstimates: Array<{ chargeCode: string; estimatedQuantity: number; estimatedTotal: number }> | null
1333
+ usageTotalEstimated: number | null
1334
+ }>,
1335
+ currencyCode: string,
1336
+ ): PricingSummary {
1337
+ let nrcTotal = 0
1338
+ let mrcTotal = 0
1339
+ let usageTotalEstimated: number | null = null
1340
+ const usageCharges: PricingSummary['usageCharges'] = []
1341
+ const allCharges: ResolvedCharge[] = []
1342
+
1343
+ for (const { charges, nrcTotal: lineNrc, mrcTotal: lineMrc, usageTotalEstimated: lineUsageEst } of computedLines) {
1344
+ nrcTotal += lineNrc
1345
+ mrcTotal += lineMrc
1346
+ if (lineUsageEst != null) {
1347
+ usageTotalEstimated = (usageTotalEstimated ?? 0) + lineUsageEst
1348
+ }
1349
+
1350
+ allCharges.push(...charges)
1351
+ for (const charge of charges) {
1352
+ if (charge.chargeType === 'usage') {
1353
+ usageCharges.push({
1354
+ chargeCode: charge.chargeCode,
1355
+ chargeName: charge.chargeName,
1356
+ unitPrice: charge.unitPrice,
1357
+ note: charge.note ?? null,
1358
+ })
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ const { discountTotal, surchargeTotal } = aggregateAdjustmentTotals(allCharges)
1364
+
1365
+ return {
1366
+ nrcTotal,
1367
+ mrcTotal,
1368
+ usageCharges,
1369
+ usageTotalEstimated,
1370
+ discountTotal,
1371
+ surchargeTotal,
1372
+ currencyCode,
1373
+ }
1374
+ }
1375
+
1376
+ function emptyPricingSummary(currencyCode: string): Record<string, unknown> {
1377
+ return {
1378
+ nrcTotal: 0,
1379
+ mrcTotal: 0,
1380
+ usageCharges: [],
1381
+ usageTotalEstimated: null,
1382
+ discountTotal: 0,
1383
+ surchargeTotal: 0,
1384
+ currencyCode,
1385
+ }
1386
+ }
1387
+
1388
+ // ─── Error class ──────────────────────────────────────────────────
1389
+
1390
+ export class QuotingError extends Error {
1391
+ constructor(
1392
+ public status: number,
1393
+ message: string,
1394
+ public cpqStatus?: string,
1395
+ /**
1396
+ * Structured payload merged into the route's JSON response body.
1397
+ * Used to surface actionable context to the UI — e.g. the
1398
+ * `existingQuoteId` of the conflicting ARC quote on a 409 from
1399
+ * `ensureNoConcurrentArcQuote`, so the operator can be offered a
1400
+ * "Open the existing quote" confirm instead of just a dead alert.
1401
+ */
1402
+ public details?: Record<string, unknown>,
1403
+ ) {
1404
+ super(message)
1405
+ this.name = 'QuotingError'
1406
+ }
1407
+ }
1408
+
1409
+ // ─── ARC orchestration (XD-250) ────────────────────────────────────
1410
+ //
1411
+ // All ARC user-journey logic lives on the CPQ Quote module:
1412
+ // • createQuoteFromSubscription — entry point from "Amend / Renew / Cancel"
1413
+ // • setQuoteType — one-way new → arc transition
1414
+ // • attach/detach/updateTargetSubscription — manage attached targets
1415
+ // • setCancelMeta / setMergeMeta — stamp ARC-specific meta on the quote
1416
+ // • validateArcQuote — invariants enforced before approval
1417
+ // • listTargetSubscriptions — readonly fetch for UI
1418
+ //
1419
+ // The actual subscription mutations happen in cpqInventoryService.apply* at
1420
+ // order activation. This service only owns the quote-level state.
1421
+
1422
+ export interface ArcRenewTermInput {
1423
+ newTermStart: string | Date
1424
+ newTermEnd: string | Date
1425
+ newTermMonths?: number | null
1426
+ }
1427
+
1428
+ export interface AttachTargetInput {
1429
+ subscriptionId: string
1430
+ quoteType: Exclude<CpqQuoteType, 'new'>
1431
+ mergeAction?: MergeAction
1432
+ newTermStart?: string | Date | null
1433
+ newTermEnd?: string | Date | null
1434
+ newTermMonths?: number | null
1435
+ }
1436
+
1437
+ export interface UpdateTargetInput {
1438
+ mergeAction?: MergeAction
1439
+ newTermStart?: string | Date | null
1440
+ newTermEnd?: string | Date | null
1441
+ newTermMonths?: number | null
1442
+ }
1443
+
1444
+ export interface CancelMetaInput {
1445
+ reasonCode: ArcReasonCode
1446
+ reasonText?: string | null
1447
+ etfAmount?: string | number | null
1448
+ etfCurrency?: string | null
1449
+ }
1450
+
1451
+ export interface MergeMetaInput {
1452
+ newTermStart: string | Date
1453
+ newTermEnd: string | Date
1454
+ newTermMonths?: number | null
1455
+ newSubCode?: string | null
1456
+ newSubName?: string | null
1457
+ }
1458
+
1459
+ export interface TargetSubscriptionView {
1460
+ id: string
1461
+ subscriptionId: string
1462
+ quoteType: string
1463
+ mergeAction: string | null
1464
+ newTermStart: string | null
1465
+ newTermEnd: string | null
1466
+ newTermMonths: number | null
1467
+ subscription: {
1468
+ code: string
1469
+ name: string
1470
+ status: string
1471
+ customerId: string
1472
+ currencyCode: string
1473
+ billingCycle: string
1474
+ currentTermStart: string | null
1475
+ currentTermEnd: string | null
1476
+ termMonths: number | null
1477
+ mrcAmount: number
1478
+ } | null
1479
+ }
1480
+
1481
+ export interface ArcQuoteValidationResult {
1482
+ ok: boolean
1483
+ errors: string[]
1484
+ }
1485
+
1486
+ const EDITABLE_QUOTE_STATUSES: ReadonlySet<string> = new Set([
1487
+ 'new',
1488
+ 'incomplete',
1489
+ 'ready',
1490
+ ])
1491
+
1492
+ function ensureEditable(cpqConfig: CpqQuoteConfiguration): void {
1493
+ if (!EDITABLE_QUOTE_STATUSES.has(cpqConfig.cpqStatus)) {
1494
+ throw new QuotingError(
1495
+ 409,
1496
+ `Cannot modify ARC attachments — quote is in '${cpqConfig.cpqStatus}' status`,
1497
+ )
1498
+ }
1499
+ }
1500
+
1501
+ function toDateOrNull(value: string | Date | null | undefined): Date | null {
1502
+ if (value == null) return null
1503
+ return value instanceof Date ? value : new Date(value)
1504
+ }
1505
+
1506
+ /**
1507
+ * Render a Date / string-date value as ISO-8601. MikroORM hydrates
1508
+ * `@Property({ type: 'date' })` columns as strings (YYYY-MM-DD) on Postgres
1509
+ * — calling `.toISOString()` on them throws. This helper accepts either form.
1510
+ */
1511
+ function toIsoOrNull(value: Date | string | null | undefined): string | null {
1512
+ if (value == null) return null
1513
+ if (value instanceof Date) return value.toISOString()
1514
+ // String already in ISO-ish form (YYYY-MM-DD or full ISO) — pass through.
1515
+ return String(value)
1516
+ }
1517
+
1518
+ // ─── Module helpers extending DefaultCpqQuotingService ─────────────
1519
+
1520
+ declare module './cpqQuotingService' {
1521
+ interface DefaultCpqQuotingService {
1522
+ setQuoteType(quoteId: string, type: CpqQuoteType, scope: TenantScope): Promise<QuoteResult>
1523
+ createQuoteFromSubscription(input: {
1524
+ subscriptionId: string
1525
+ type: Exclude<CpqQuoteType, 'new'>
1526
+ renewTerm?: ArcRenewTermInput
1527
+ }, scope: TenantScope): Promise<{ quoteId: string; cpqConfigId: string }>
1528
+ listTargetSubscriptions(quoteId: string, scope: TenantScope): Promise<TargetSubscriptionView[]>
1529
+ attachTargetSubscription(
1530
+ quoteId: string,
1531
+ input: AttachTargetInput,
1532
+ scope: TenantScope,
1533
+ ): Promise<TargetSubscriptionView>
1534
+ detachTargetSubscription(
1535
+ quoteId: string,
1536
+ targetId: string,
1537
+ scope: TenantScope,
1538
+ ): Promise<{ ok: true }>
1539
+ updateTargetSubscription(
1540
+ quoteId: string,
1541
+ targetId: string,
1542
+ patch: UpdateTargetInput,
1543
+ scope: TenantScope,
1544
+ ): Promise<TargetSubscriptionView>
1545
+ setCancelMeta(quoteId: string, meta: CancelMetaInput, scope: TenantScope): Promise<QuoteResult>
1546
+ setMergeMeta(quoteId: string, meta: MergeMetaInput, scope: TenantScope): Promise<QuoteResult>
1547
+ validateArcQuote(quoteId: string, scope: TenantScope): Promise<ArcQuoteValidationResult>
1548
+ }
1549
+ }
1550
+
1551
+ DefaultCpqQuotingService.prototype.setQuoteType = async function (
1552
+ this: DefaultCpqQuotingService,
1553
+ quoteId: string,
1554
+ type: CpqQuoteType,
1555
+ scope: TenantScope,
1556
+ ): Promise<QuoteResult> {
1557
+ const em = (this as unknown as { em: EntityManager }).em
1558
+ const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
1559
+ ensureEditable(cpqConfig)
1560
+
1561
+ if (cpqConfig.quoteType === type) {
1562
+ // No-op — caller can repeat the PATCH freely.
1563
+ } else if (cpqConfig.quoteType !== 'new') {
1564
+ throw new QuotingError(409, `Quote type is immutable (current: '${cpqConfig.quoteType}')`)
1565
+ } else if (type === 'new') {
1566
+ throw new QuotingError(400, `Cannot transition quote type back to 'new'`)
1567
+ } else {
1568
+ cpqConfig.quoteType = type
1569
+ await em.flush()
1570
+ }
1571
+
1572
+ const salesQuote = await loadSalesQuoteStrict(em, cpqConfig.quoteId, scope)
1573
+ const lines = await loadLines(em, cpqConfig.id, scope)
1574
+ return (this as unknown as InternalQuoting).buildQuoteResult(em, cpqConfig, salesQuote, lines)
1575
+ }
1576
+
1577
+ DefaultCpqQuotingService.prototype.createQuoteFromSubscription = async function (
1578
+ this: DefaultCpqQuotingService,
1579
+ input: {
1580
+ subscriptionId: string
1581
+ type: Exclude<CpqQuoteType, 'new'>
1582
+ renewTerm?: ArcRenewTermInput
1583
+ },
1584
+ scope: TenantScope,
1585
+ ): Promise<{ quoteId: string; cpqConfigId: string }> {
1586
+ const em = (this as unknown as { em: EntityManager }).em
1587
+
1588
+ // 1. Load source subscription + active items.
1589
+ const sub = await em.findOne(CpqInventorySubscription, {
1590
+ id: input.subscriptionId,
1591
+ organizationId: scope.organizationId,
1592
+ tenantId: scope.tenantId,
1593
+ deletedAt: null,
1594
+ })
1595
+ if (!sub) throw new QuotingError(404, 'Subscription not found')
1596
+ if (sub.status !== 'active' && sub.status !== 'suspended') {
1597
+ throw new QuotingError(
1598
+ 409,
1599
+ `Subscription must be active or suspended (current: '${sub.status}')`,
1600
+ )
1601
+ }
1602
+
1603
+ await ensureNoConcurrentArcQuote(em, sub.id, scope)
1604
+
1605
+ const items = await em.find(CpqInventorySubscriptionItem, {
1606
+ subscriptionId: sub.id,
1607
+ organizationId: scope.organizationId,
1608
+ tenantId: scope.tenantId,
1609
+ deletedAt: null,
1610
+ status: { $nin: ['terminated', 'expired', 'superseded'] },
1611
+ })
1612
+
1613
+ // 2. Create SalesQuote + CpqQuoteConfiguration with quoteType=type.
1614
+ const numberGenerator = (this as unknown as InternalQuoting).numberGenerator
1615
+ const { number: quoteNumber } = await numberGenerator.generate({
1616
+ kind: 'quote',
1617
+ organizationId: scope.organizationId,
1618
+ tenantId: scope.tenantId,
1619
+ })
1620
+
1621
+ const salesQuoteId = crypto.randomUUID()
1622
+ const salesQuote = em.create(SalesQuote, {
1623
+ id: salesQuoteId,
1624
+ organizationId: scope.organizationId,
1625
+ tenantId: scope.tenantId,
1626
+ quoteNumber,
1627
+ customerEntityId: sub.customerId,
1628
+ currencyCode: sub.currencyCode,
1629
+ subtotalNetAmount: '0',
1630
+ subtotalGrossAmount: '0',
1631
+ discountTotalAmount: '0',
1632
+ taxTotalAmount: '0',
1633
+ grandTotalNetAmount: '0',
1634
+ grandTotalGrossAmount: '0',
1635
+ lineItemCount: 0,
1636
+ createdAt: new Date(),
1637
+ updatedAt: new Date(),
1638
+ } as never)
1639
+ em.persist(salesQuote)
1640
+
1641
+ // Pre-generate id so child rows (target subscription + quote lines) can
1642
+ // reference it before flush. The DB-side `defaultRaw: 'gen_random_uuid()'`
1643
+ // only fires at INSERT, not at em.create() — so the id would be undefined
1644
+ // when CpqQuoteLineConfiguration tries to set quoteConfigurationId.
1645
+ const cpqConfigId = crypto.randomUUID()
1646
+ const cpqConfig = em.create(CpqQuoteConfiguration, {
1647
+ id: cpqConfigId,
1648
+ organizationId: scope.organizationId,
1649
+ tenantId: scope.tenantId,
1650
+ quoteId: salesQuoteId,
1651
+ customerId: sub.customerId,
1652
+ cpqStatus: 'new',
1653
+ version: 1,
1654
+ currencyCode: sub.currencyCode,
1655
+ quoteType: input.type,
1656
+ quoteContext: { arcSourceSubscriptionId: sub.id },
1657
+ validationResult: { valid: true, errors: [] },
1658
+ pricingSummary: emptyArcPricingSummary(sub.currencyCode),
1659
+ })
1660
+ em.persist(cpqConfig)
1661
+
1662
+ // 3. Attach the source subscription as the (sole) target. For renew, fall
1663
+ // back to "renew for same length starting at current term end" so the
1664
+ // operator can hit Convert→Activate without opening the drawer at all
1665
+ // when the default behaviour is fine. Operator override is still
1666
+ // available via the drawer's config-renew step.
1667
+ const renewDefaults = input.type === 'renew' ? buildRenewDefaults(sub, input.renewTerm) : null
1668
+ const target = em.create(CpqQuoteTargetSubscription, {
1669
+ organizationId: scope.organizationId,
1670
+ tenantId: scope.tenantId,
1671
+ quoteId: cpqConfig.id,
1672
+ subscriptionId: sub.id,
1673
+ quoteType: input.type,
1674
+ mergeAction: input.type === 'renew' ? 'standalone' : null,
1675
+ newTermStart: renewDefaults?.start ?? null,
1676
+ newTermEnd: renewDefaults?.end ?? null,
1677
+ newTermMonths: renewDefaults?.months ?? null,
1678
+ })
1679
+ em.persist(target)
1680
+
1681
+ // 4. Pre-fill quote lines from the subscription's active items via the
1682
+ // shared mirror helper (also used when attaching extra targets).
1683
+ await mirrorTargetSubscriptionItems(em, cpqConfig, sub, salesQuote, input.type, scope)
1684
+
1685
+ await em.flush()
1686
+ return { quoteId: salesQuoteId, cpqConfigId: cpqConfig.id }
1687
+ }
1688
+
1689
+ DefaultCpqQuotingService.prototype.listTargetSubscriptions = async function (
1690
+ this: DefaultCpqQuotingService,
1691
+ quoteId: string,
1692
+ scope: TenantScope,
1693
+ ): Promise<TargetSubscriptionView[]> {
1694
+ const em = (this as unknown as { em: EntityManager }).em
1695
+ const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
1696
+ const targets = await em.find(CpqQuoteTargetSubscription, {
1697
+ quoteId: cpqConfig.id,
1698
+ organizationId: scope.organizationId,
1699
+ tenantId: scope.tenantId,
1700
+ deletedAt: null,
1701
+ })
1702
+
1703
+ if (targets.length === 0) return []
1704
+
1705
+ const subs = await em.find(CpqInventorySubscription, {
1706
+ id: { $in: targets.map((t) => t.subscriptionId) },
1707
+ organizationId: scope.organizationId,
1708
+ tenantId: scope.tenantId,
1709
+ deletedAt: null,
1710
+ })
1711
+ const subMap = new Map(subs.map((s) => [s.id, s]))
1712
+
1713
+ return targets.map((t) => {
1714
+ const s = subMap.get(t.subscriptionId)
1715
+ return {
1716
+ id: t.id,
1717
+ subscriptionId: t.subscriptionId,
1718
+ quoteType: t.quoteType,
1719
+ mergeAction: t.mergeAction ?? null,
1720
+ newTermStart: toIsoOrNull(t.newTermStart),
1721
+ newTermEnd: toIsoOrNull(t.newTermEnd),
1722
+ newTermMonths: t.newTermMonths ?? null,
1723
+ subscription: s
1724
+ ? {
1725
+ code: s.code,
1726
+ name: s.name,
1727
+ status: s.status,
1728
+ customerId: s.customerId,
1729
+ currencyCode: s.currencyCode,
1730
+ billingCycle: s.billingCycle,
1731
+ currentTermStart: toIsoOrNull(s.currentTermStart),
1732
+ currentTermEnd: toIsoOrNull(s.currentTermEnd),
1733
+ termMonths: s.termMonths ?? null,
1734
+ mrcAmount: Number(s.mrcAmount),
1735
+ }
1736
+ : null,
1737
+ }
1738
+ })
1739
+ }
1740
+
1741
+ DefaultCpqQuotingService.prototype.attachTargetSubscription = async function (
1742
+ this: DefaultCpqQuotingService,
1743
+ quoteId: string,
1744
+ input: AttachTargetInput,
1745
+ scope: TenantScope,
1746
+ ): Promise<TargetSubscriptionView> {
1747
+ const em = (this as unknown as { em: EntityManager }).em
1748
+ const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
1749
+ ensureEditable(cpqConfig)
1750
+
1751
+ if (cpqConfig.quoteType === 'new') {
1752
+ throw new QuotingError(409, `Cannot attach a target subscription — quote_type is 'new'`)
1753
+ }
1754
+ if (cpqConfig.quoteType !== input.quoteType) {
1755
+ throw new QuotingError(
1756
+ 400,
1757
+ `Quote type mismatch: quote is '${cpqConfig.quoteType}', payload says '${input.quoteType}'`,
1758
+ )
1759
+ }
1760
+
1761
+ // Idempotency on (quote, subscription).
1762
+ const existing = await em.findOne(CpqQuoteTargetSubscription, {
1763
+ organizationId: scope.organizationId,
1764
+ tenantId: scope.tenantId,
1765
+ deletedAt: null,
1766
+ quoteId: cpqConfig.id,
1767
+ subscriptionId: input.subscriptionId,
1768
+ })
1769
+ if (existing) {
1770
+ return mapTargetView(em, existing, scope)
1771
+ }
1772
+
1773
+ // Subscription must exist and be in {active, suspended}.
1774
+ const sub = await em.findOne(CpqInventorySubscription, {
1775
+ id: input.subscriptionId,
1776
+ organizationId: scope.organizationId,
1777
+ tenantId: scope.tenantId,
1778
+ deletedAt: null,
1779
+ })
1780
+ if (!sub) throw new QuotingError(404, 'Subscription not found')
1781
+ if (sub.status !== 'active' && sub.status !== 'suspended') {
1782
+ throw new QuotingError(
1783
+ 409,
1784
+ `Subscription must be active or suspended (current: '${sub.status}')`,
1785
+ )
1786
+ }
1787
+
1788
+ // Customer / currency / billingCycle must match the quote and any existing targets.
1789
+ if (sub.customerId !== cpqConfig.customerId) {
1790
+ throw new QuotingError(400, 'Subscription customer must match quote customer')
1791
+ }
1792
+ if (sub.currencyCode !== cpqConfig.currencyCode) {
1793
+ throw new QuotingError(400, 'Subscription currency must match quote currency')
1794
+ }
1795
+
1796
+ const otherTargets = await em.find(CpqQuoteTargetSubscription, {
1797
+ organizationId: scope.organizationId,
1798
+ tenantId: scope.tenantId,
1799
+ deletedAt: null,
1800
+ quoteId: cpqConfig.id,
1801
+ })
1802
+ if (input.quoteType === 'renew' && otherTargets.length > 0) {
1803
+ // Merge mode: every target (existing + this) must share billingCycle.
1804
+ const otherSubs = await em.find(CpqInventorySubscription, {
1805
+ id: { $in: otherTargets.map((t) => t.subscriptionId) },
1806
+ organizationId: scope.organizationId,
1807
+ tenantId: scope.tenantId,
1808
+ deletedAt: null,
1809
+ })
1810
+ for (const o of otherSubs) {
1811
+ if (o.billingCycle !== sub.billingCycle) {
1812
+ throw new QuotingError(
1813
+ 400,
1814
+ 'Merge targets must share the same billing cycle',
1815
+ )
1816
+ }
1817
+ }
1818
+ }
1819
+
1820
+ await ensureNoConcurrentArcQuote(em, input.subscriptionId, scope, cpqConfig.id)
1821
+
1822
+ const target = em.create(CpqQuoteTargetSubscription, {
1823
+ organizationId: scope.organizationId,
1824
+ tenantId: scope.tenantId,
1825
+ quoteId: cpqConfig.id,
1826
+ subscriptionId: input.subscriptionId,
1827
+ quoteType: input.quoteType,
1828
+ mergeAction:
1829
+ input.quoteType === 'renew'
1830
+ ? input.mergeAction ?? (otherTargets.length === 0 ? 'standalone' : 'absorb')
1831
+ : null,
1832
+ newTermStart: toDateOrNull(input.newTermStart),
1833
+ newTermEnd: toDateOrNull(input.newTermEnd),
1834
+ newTermMonths: input.newTermMonths ?? null,
1835
+ })
1836
+ em.persist(target)
1837
+ await em.flush()
1838
+
1839
+ // After attaching a 2nd renew target, retroactively flip the original
1840
+ // 'standalone' target to 'absorb' so the whole quote enters merge mode.
1841
+ let renewMergeJustEntered = false
1842
+ if (input.quoteType === 'renew') {
1843
+ const allTargets = await em.find(CpqQuoteTargetSubscription, {
1844
+ quoteId: cpqConfig.id,
1845
+ organizationId: scope.organizationId,
1846
+ tenantId: scope.tenantId,
1847
+ deletedAt: null,
1848
+ })
1849
+ if (allTargets.length >= 2) {
1850
+ const wasStandalone = allTargets.some((t) => t.mergeAction === 'standalone')
1851
+ for (const t of allTargets) {
1852
+ t.mergeAction = 'absorb'
1853
+ t.newTermStart = null
1854
+ t.newTermEnd = null
1855
+ t.newTermMonths = null
1856
+ }
1857
+ await em.flush()
1858
+ renewMergeJustEntered = wasStandalone
1859
+ }
1860
+ }
1861
+
1862
+ // XD-250 multi-target ARC: mirror the newly attached subscription's items
1863
+ // so the operator can see / edit / cancel them in the quote UI. Without
1864
+ // this the target row existed but the quote had no lines for it, so
1865
+ // activation produced an empty change-log entry on that sub. For renew
1866
+ // merge, mirrored lines carry `targetSubscriptionId: null` (the merged
1867
+ // sub doesn't exist yet at quote time) and any lines created earlier for
1868
+ // the original standalone target get re-tagged to null too.
1869
+ const salesQuote = await em.findOne(SalesQuote, {
1870
+ id: cpqConfig.quoteId,
1871
+ organizationId: scope.organizationId,
1872
+ tenantId: scope.tenantId,
1873
+ })
1874
+ if (salesQuote) {
1875
+ const isRenewMerge = input.quoteType === 'renew' && otherTargets.length > 0
1876
+ await mirrorTargetSubscriptionItems(
1877
+ em,
1878
+ cpqConfig,
1879
+ sub,
1880
+ salesQuote,
1881
+ input.quoteType,
1882
+ scope,
1883
+ { mergeMode: isRenewMerge },
1884
+ )
1885
+
1886
+ if (renewMergeJustEntered) {
1887
+ const existingLines = await em.find(CpqQuoteLineConfiguration, {
1888
+ quoteConfigurationId: cpqConfig.id,
1889
+ organizationId: scope.organizationId,
1890
+ tenantId: scope.tenantId,
1891
+ deletedAt: null,
1892
+ })
1893
+ for (const lc of existingLines) {
1894
+ if (lc.targetSubscriptionId !== null) lc.targetSubscriptionId = null
1895
+ }
1896
+ }
1897
+
1898
+ await em.flush()
1899
+ }
1900
+
1901
+ return mapTargetView(em, target, scope)
1902
+ }
1903
+
1904
+ DefaultCpqQuotingService.prototype.detachTargetSubscription = async function (
1905
+ this: DefaultCpqQuotingService,
1906
+ quoteId: string,
1907
+ targetId: string,
1908
+ scope: TenantScope,
1909
+ ): Promise<{ ok: true }> {
1910
+ const em = (this as unknown as { em: EntityManager }).em
1911
+ const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
1912
+ ensureEditable(cpqConfig)
1913
+
1914
+ const target = await em.findOne(CpqQuoteTargetSubscription, {
1915
+ id: targetId,
1916
+ quoteId: cpqConfig.id,
1917
+ organizationId: scope.organizationId,
1918
+ tenantId: scope.tenantId,
1919
+ deletedAt: null,
1920
+ })
1921
+ if (!target) throw new QuotingError(404, 'Target subscription not found')
1922
+
1923
+ // Drop any quote lines that pointed at this target.
1924
+ const lines = await em.find(CpqQuoteLineConfiguration, {
1925
+ quoteConfigurationId: cpqConfig.id,
1926
+ targetSubscriptionId: target.subscriptionId,
1927
+ organizationId: scope.organizationId,
1928
+ tenantId: scope.tenantId,
1929
+ deletedAt: null,
1930
+ })
1931
+ const now = new Date()
1932
+ for (const line of lines) {
1933
+ line.deletedAt = now
1934
+ const salesLine = await em.findOne(SalesQuoteLine, { id: line.quoteLineId })
1935
+ if (salesLine) salesLine.deletedAt = now
1936
+ }
1937
+
1938
+ target.deletedAt = now
1939
+
1940
+ // Auto-revert merge mode if only one renew target remains.
1941
+ if (target.quoteType === 'renew') {
1942
+ const remaining = await em.find(CpqQuoteTargetSubscription, {
1943
+ quoteId: cpqConfig.id,
1944
+ organizationId: scope.organizationId,
1945
+ tenantId: scope.tenantId,
1946
+ deletedAt: null,
1947
+ id: { $ne: target.id },
1948
+ })
1949
+ if (remaining.length === 1) {
1950
+ remaining[0].mergeAction = 'standalone'
1951
+ }
1952
+ }
1953
+
1954
+ await em.flush()
1955
+ return { ok: true }
1956
+ }
1957
+
1958
+ DefaultCpqQuotingService.prototype.updateTargetSubscription = async function (
1959
+ this: DefaultCpqQuotingService,
1960
+ quoteId: string,
1961
+ targetId: string,
1962
+ patch: UpdateTargetInput,
1963
+ scope: TenantScope,
1964
+ ): Promise<TargetSubscriptionView> {
1965
+ const em = (this as unknown as { em: EntityManager }).em
1966
+ const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
1967
+ ensureEditable(cpqConfig)
1968
+
1969
+ const target = await em.findOne(CpqQuoteTargetSubscription, {
1970
+ id: targetId,
1971
+ quoteId: cpqConfig.id,
1972
+ organizationId: scope.organizationId,
1973
+ tenantId: scope.tenantId,
1974
+ deletedAt: null,
1975
+ })
1976
+ if (!target) throw new QuotingError(404, 'Target subscription not found')
1977
+
1978
+ if (target.quoteType !== 'renew') {
1979
+ throw new QuotingError(
1980
+ 409,
1981
+ `Cannot update target on a '${target.quoteType}' quote — only renew targets carry term/merge fields`,
1982
+ )
1983
+ }
1984
+
1985
+ if (patch.mergeAction !== undefined) target.mergeAction = patch.mergeAction
1986
+ if (patch.newTermStart !== undefined) target.newTermStart = toDateOrNull(patch.newTermStart)
1987
+ if (patch.newTermEnd !== undefined) target.newTermEnd = toDateOrNull(patch.newTermEnd)
1988
+ if (patch.newTermMonths !== undefined) target.newTermMonths = patch.newTermMonths ?? null
1989
+
1990
+ await em.flush()
1991
+ return mapTargetView(em, target, scope)
1992
+ }
1993
+
1994
+ DefaultCpqQuotingService.prototype.setCancelMeta = async function (
1995
+ this: DefaultCpqQuotingService,
1996
+ quoteId: string,
1997
+ meta: CancelMetaInput,
1998
+ scope: TenantScope,
1999
+ ): Promise<QuoteResult> {
2000
+ const em = (this as unknown as { em: EntityManager }).em
2001
+ const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
2002
+ ensureEditable(cpqConfig)
2003
+ if (cpqConfig.quoteType !== 'cancel') {
2004
+ throw new QuotingError(
2005
+ 409,
2006
+ `Cancel meta can only be set on a cancel quote (current: '${cpqConfig.quoteType}')`,
2007
+ )
2008
+ }
2009
+ cpqConfig.arcReasonCode = meta.reasonCode
2010
+ cpqConfig.arcReasonText = meta.reasonText ?? null
2011
+ cpqConfig.arcEtfAmount = meta.etfAmount != null ? String(meta.etfAmount) : null
2012
+ cpqConfig.arcEtfCurrency = meta.etfCurrency ?? cpqConfig.currencyCode
2013
+ await em.flush()
2014
+
2015
+ const salesQuote = await loadSalesQuoteStrict(em, cpqConfig.quoteId, scope)
2016
+ const lines = await loadLines(em, cpqConfig.id, scope)
2017
+ return (this as unknown as InternalQuoting).buildQuoteResult(em, cpqConfig, salesQuote, lines)
2018
+ }
2019
+
2020
+ DefaultCpqQuotingService.prototype.setMergeMeta = async function (
2021
+ this: DefaultCpqQuotingService,
2022
+ quoteId: string,
2023
+ meta: MergeMetaInput,
2024
+ scope: TenantScope,
2025
+ ): Promise<QuoteResult> {
2026
+ const em = (this as unknown as { em: EntityManager }).em
2027
+ const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
2028
+ ensureEditable(cpqConfig)
2029
+ if (cpqConfig.quoteType !== 'renew') {
2030
+ throw new QuotingError(
2031
+ 409,
2032
+ `Merge meta can only be set on a renew quote (current: '${cpqConfig.quoteType}')`,
2033
+ )
2034
+ }
2035
+ cpqConfig.arcMergeNewTermStart = toDateOrNull(meta.newTermStart)
2036
+ cpqConfig.arcMergeNewTermEnd = toDateOrNull(meta.newTermEnd)
2037
+ cpqConfig.arcMergeNewTermMonths = meta.newTermMonths ?? null
2038
+ cpqConfig.arcMergeNewSubCode = meta.newSubCode ?? null
2039
+ cpqConfig.arcMergeNewSubName = meta.newSubName ?? null
2040
+ await em.flush()
2041
+
2042
+ const salesQuote = await loadSalesQuoteStrict(em, cpqConfig.quoteId, scope)
2043
+ const lines = await loadLines(em, cpqConfig.id, scope)
2044
+ return (this as unknown as InternalQuoting).buildQuoteResult(em, cpqConfig, salesQuote, lines)
2045
+ }
2046
+
2047
+ DefaultCpqQuotingService.prototype.validateArcQuote = async function (
2048
+ this: DefaultCpqQuotingService,
2049
+ quoteId: string,
2050
+ scope: TenantScope,
2051
+ ): Promise<ArcQuoteValidationResult> {
2052
+ const em = (this as unknown as { em: EntityManager }).em
2053
+ const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
2054
+ const errors: string[] = []
2055
+
2056
+ if (cpqConfig.quoteType === 'new') {
2057
+ return { ok: true, errors: [] }
2058
+ }
2059
+ if (!ARC_QUOTE_TYPES.includes(cpqConfig.quoteType as Exclude<CpqQuoteType, 'new'>)) {
2060
+ errors.push(`Unknown quote type '${cpqConfig.quoteType}'`)
2061
+ return { ok: false, errors }
2062
+ }
2063
+
2064
+ const targets = await em.find(CpqQuoteTargetSubscription, {
2065
+ quoteId: cpqConfig.id,
2066
+ organizationId: scope.organizationId,
2067
+ tenantId: scope.tenantId,
2068
+ deletedAt: null,
2069
+ })
2070
+ if (targets.length === 0) {
2071
+ errors.push(`ARC quote requires at least one target subscription`)
2072
+ return { ok: false, errors }
2073
+ }
2074
+
2075
+ // Fetch attached subscriptions and check status + same customer/currency/billingCycle.
2076
+ const subs = await em.find(CpqInventorySubscription, {
2077
+ id: { $in: targets.map((t) => t.subscriptionId) },
2078
+ organizationId: scope.organizationId,
2079
+ tenantId: scope.tenantId,
2080
+ deletedAt: null,
2081
+ })
2082
+ const subMap = new Map(subs.map((s) => [s.id, s]))
2083
+ for (const t of targets) {
2084
+ const s = subMap.get(t.subscriptionId)
2085
+ if (!s) {
2086
+ errors.push(`Target subscription ${t.subscriptionId} not found`)
2087
+ continue
2088
+ }
2089
+ if (s.status !== 'active' && s.status !== 'suspended') {
2090
+ errors.push(
2091
+ `Target subscription ${s.code} must be active or suspended (current: '${s.status}')`,
2092
+ )
2093
+ }
2094
+ if (s.customerId !== cpqConfig.customerId) {
2095
+ errors.push(`Target subscription ${s.code} belongs to a different customer`)
2096
+ }
2097
+ if (s.currencyCode !== cpqConfig.currencyCode) {
2098
+ errors.push(`Target subscription ${s.code} has currency mismatch`)
2099
+ }
2100
+ }
2101
+
2102
+ // Type-specific invariants.
2103
+ if (cpqConfig.quoteType === 'renew') {
2104
+ const standaloneCount = targets.filter((t) => t.mergeAction === 'standalone').length
2105
+ const absorbCount = targets.filter((t) => t.mergeAction === 'absorb').length
2106
+
2107
+ if (targets.length === 1) {
2108
+ if (standaloneCount !== 1) {
2109
+ errors.push(`Single-target renew must have merge_action='standalone'`)
2110
+ }
2111
+ const t = targets[0]
2112
+ if (!t.newTermStart || !t.newTermEnd) {
2113
+ errors.push(`Standalone renew target must have newTermStart and newTermEnd`)
2114
+ } else if (t.newTermStart >= t.newTermEnd) {
2115
+ errors.push(`newTermStart must be earlier than newTermEnd`)
2116
+ }
2117
+ } else {
2118
+ if (absorbCount !== targets.length) {
2119
+ errors.push(`Multi-target renew (merge) requires every target merge_action='absorb'`)
2120
+ }
2121
+ if (
2122
+ !cpqConfig.arcMergeNewTermStart ||
2123
+ !cpqConfig.arcMergeNewTermEnd
2124
+ ) {
2125
+ errors.push(`Merge renew requires quote-level arc_merge_new_term_start and arc_merge_new_term_end`)
2126
+ } else if (cpqConfig.arcMergeNewTermStart >= cpqConfig.arcMergeNewTermEnd) {
2127
+ errors.push(`arc_merge_new_term_start must be earlier than arc_merge_new_term_end`)
2128
+ }
2129
+ // Same billing cycle across all sources.
2130
+ const cycles = new Set(
2131
+ targets
2132
+ .map((t) => subMap.get(t.subscriptionId)?.billingCycle)
2133
+ .filter(Boolean),
2134
+ )
2135
+ if (cycles.size > 1) {
2136
+ errors.push(`Merge targets must share the same billing cycle`)
2137
+ }
2138
+ }
2139
+ }
2140
+
2141
+ if (cpqConfig.quoteType === 'cancel') {
2142
+ if (!cpqConfig.arcReasonCode) {
2143
+ errors.push(`Cancel quote requires arc_reason_code (call /cancel-meta first)`)
2144
+ }
2145
+ }
2146
+
2147
+ // Quote line invariants.
2148
+ const lines = await loadLines(em, cpqConfig.id, scope)
2149
+ const targetSubIds = new Set(targets.map((t) => t.subscriptionId))
2150
+ const isMerge =
2151
+ cpqConfig.quoteType === 'renew' &&
2152
+ targets.length >= 2 &&
2153
+ targets.every((t) => t.mergeAction === 'absorb')
2154
+
2155
+ for (const line of lines) {
2156
+ if (cpqConfig.quoteType === 'amend' || cpqConfig.quoteType === 'cancel') {
2157
+ if (!line.targetSubscriptionId) {
2158
+ errors.push(`Quote line ${line.quoteLineId} missing target_subscription_id`)
2159
+ } else if (!targetSubIds.has(line.targetSubscriptionId)) {
2160
+ errors.push(
2161
+ `Quote line ${line.quoteLineId} target_subscription_id does not match any attached target`,
2162
+ )
2163
+ }
2164
+ } else if (cpqConfig.quoteType === 'renew') {
2165
+ if (isMerge) {
2166
+ if (line.targetSubscriptionId) {
2167
+ errors.push(
2168
+ `Merge-renew line ${line.quoteLineId} must have null target_subscription_id`,
2169
+ )
2170
+ }
2171
+ } else {
2172
+ // standalone — single target.
2173
+ if (!line.targetSubscriptionId || !targetSubIds.has(line.targetSubscriptionId)) {
2174
+ errors.push(
2175
+ `Standalone renew line ${line.quoteLineId} must target the renewing subscription`,
2176
+ )
2177
+ }
2178
+ }
2179
+ }
2180
+ if (
2181
+ (line.action === 'cancel' || line.action === 'modify') &&
2182
+ cpqConfig.quoteType !== 'cancel' &&
2183
+ !line.sourceSubscriptionItemId
2184
+ ) {
2185
+ errors.push(
2186
+ `Quote line ${line.quoteLineId} action='${line.action}' requires source_subscription_item_id`,
2187
+ )
2188
+ }
2189
+ }
2190
+
2191
+ // Concurrency: each attached sub must not be on another non-terminal ARC quote.
2192
+ for (const t of targets) {
2193
+ await ensureNoConcurrentArcQuoteForValidation(em, t.subscriptionId, scope, cpqConfig.id, errors)
2194
+ }
2195
+
2196
+ return { ok: errors.length === 0, errors }
2197
+ }
2198
+
2199
+ // ─── ARC private/module helpers (XD-250) ───────────────────────────
2200
+
2201
+ interface InternalQuoting {
2202
+ numberGenerator: SalesDocumentNumberGenerator
2203
+ buildQuoteResult: (
2204
+ em: EntityManager,
2205
+ cpqConfig: CpqQuoteConfiguration,
2206
+ salesQuote: SalesQuote,
2207
+ lineConfigs: CpqQuoteLineConfiguration[],
2208
+ validationResult?: { valid: boolean; errors: ValidationError[] },
2209
+ ) => Promise<QuoteResult>
2210
+ }
2211
+
2212
+ async function loadCpqConfigStrict(
2213
+ em: EntityManager,
2214
+ quoteId: string,
2215
+ scope: TenantScope,
2216
+ ): Promise<CpqQuoteConfiguration> {
2217
+ let config = await em.findOne(CpqQuoteConfiguration, {
2218
+ id: quoteId,
2219
+ organizationId: scope.organizationId,
2220
+ tenantId: scope.tenantId,
2221
+ deletedAt: null,
2222
+ })
2223
+ if (!config) {
2224
+ config = await em.findOne(CpqQuoteConfiguration, {
2225
+ quoteId,
2226
+ organizationId: scope.organizationId,
2227
+ tenantId: scope.tenantId,
2228
+ deletedAt: null,
2229
+ })
2230
+ }
2231
+ if (!config) throw new QuotingError(404, 'Quote not found')
2232
+ return config
2233
+ }
2234
+
2235
+ async function loadSalesQuoteStrict(
2236
+ em: EntityManager,
2237
+ quoteId: string,
2238
+ scope: TenantScope,
2239
+ ): Promise<SalesQuote> {
2240
+ const quote = await em.findOne(SalesQuote, {
2241
+ id: quoteId,
2242
+ organizationId: scope.organizationId,
2243
+ tenantId: scope.tenantId,
2244
+ })
2245
+ if (!quote) throw new QuotingError(404, 'Sales quote not found')
2246
+ return quote
2247
+ }
2248
+
2249
+ async function loadLines(
2250
+ em: EntityManager,
2251
+ cpqConfigId: string,
2252
+ scope: TenantScope,
2253
+ ): Promise<CpqQuoteLineConfiguration[]> {
2254
+ return em.find(CpqQuoteLineConfiguration, {
2255
+ quoteConfigurationId: cpqConfigId,
2256
+ organizationId: scope.organizationId,
2257
+ tenantId: scope.tenantId,
2258
+ deletedAt: null,
2259
+ })
2260
+ }
2261
+
2262
+ async function ensureNoConcurrentArcQuote(
2263
+ em: EntityManager,
2264
+ subscriptionId: string,
2265
+ scope: TenantScope,
2266
+ excludeQuoteConfigId?: string,
2267
+ ): Promise<void> {
2268
+ const conflicting = await em.find(CpqQuoteTargetSubscription, {
2269
+ subscriptionId,
2270
+ organizationId: scope.organizationId,
2271
+ tenantId: scope.tenantId,
2272
+ deletedAt: null,
2273
+ })
2274
+ if (conflicting.length === 0) return
2275
+ const cfgIds = conflicting.map((c) => c.quoteId)
2276
+ const cfgs = await em.find(CpqQuoteConfiguration, {
2277
+ id: { $in: cfgIds },
2278
+ organizationId: scope.organizationId,
2279
+ tenantId: scope.tenantId,
2280
+ deletedAt: null,
2281
+ })
2282
+ for (const cfg of cfgs) {
2283
+ if (excludeQuoteConfigId && cfg.id === excludeQuoteConfigId) continue
2284
+ if (!TERMINAL_STATUSES.includes(cfg.cpqStatus as CpqStatus)) {
2285
+ throw new QuotingError(
2286
+ 409,
2287
+ `Subscription has another ARC quote in progress (quoteId=${cfg.quoteId})`,
2288
+ undefined,
2289
+ // Surface the conflicting quote id so the UI can offer "Open the
2290
+ // existing quote" instead of just showing a dead-end alert. Use
2291
+ // `quoteId` (the SalesQuote UUID, what the URL bar shows after the
2292
+ // first Amend) — not `cfg.id` (the CpqQuoteConfiguration UUID) —
2293
+ // so the redirect URL matches the URL the operator would see on a
2294
+ // fresh first-time create, and so the test assertion can compare
2295
+ // the two with `toContain`.
2296
+ { existingQuoteId: cfg.quoteId, existingQuoteStatus: cfg.cpqStatus },
2297
+ )
2298
+ }
2299
+ }
2300
+ }
2301
+
2302
+ async function ensureNoConcurrentArcQuoteForValidation(
2303
+ em: EntityManager,
2304
+ subscriptionId: string,
2305
+ scope: TenantScope,
2306
+ excludeQuoteConfigId: string,
2307
+ errors: string[],
2308
+ ): Promise<void> {
2309
+ try {
2310
+ await ensureNoConcurrentArcQuote(em, subscriptionId, scope, excludeQuoteConfigId)
2311
+ } catch (err) {
2312
+ if (err instanceof QuotingError) errors.push(err.message)
2313
+ else throw err
2314
+ }
2315
+ }
2316
+
2317
+ async function mapTargetView(
2318
+ em: EntityManager,
2319
+ target: CpqQuoteTargetSubscription,
2320
+ scope: TenantScope,
2321
+ ): Promise<TargetSubscriptionView> {
2322
+ const sub = await em.findOne(CpqInventorySubscription, {
2323
+ id: target.subscriptionId,
2324
+ organizationId: scope.organizationId,
2325
+ tenantId: scope.tenantId,
2326
+ deletedAt: null,
2327
+ })
2328
+ return {
2329
+ id: target.id,
2330
+ subscriptionId: target.subscriptionId,
2331
+ quoteType: target.quoteType,
2332
+ mergeAction: target.mergeAction ?? null,
2333
+ newTermStart: toIsoOrNull(target.newTermStart),
2334
+ newTermEnd: toIsoOrNull(target.newTermEnd),
2335
+ newTermMonths: target.newTermMonths ?? null,
2336
+ subscription: sub
2337
+ ? {
2338
+ code: sub.code,
2339
+ name: sub.name,
2340
+ status: sub.status,
2341
+ customerId: sub.customerId,
2342
+ currencyCode: sub.currencyCode,
2343
+ billingCycle: sub.billingCycle,
2344
+ currentTermStart: toIsoOrNull(sub.currentTermStart),
2345
+ currentTermEnd: toIsoOrNull(sub.currentTermEnd),
2346
+ termMonths: sub.termMonths ?? null,
2347
+ mrcAmount: Number(sub.mrcAmount),
2348
+ }
2349
+ : null,
2350
+ }
2351
+ }
2352
+
2353
+ function emptyArcPricingSummary(currencyCode: string): Record<string, unknown> {
2354
+ return {
2355
+ nrcTotal: 0,
2356
+ mrcTotal: 0,
2357
+ usageCharges: [],
2358
+ usageTotalEstimated: null,
2359
+ discountTotal: 0,
2360
+ surchargeTotal: 0,
2361
+ currencyCode,
2362
+ }
2363
+ }
2364
+
2365
+ // XD-250 ARC: batch-load source subscription items referenced by the lines'
2366
+ // `_arcMirroredFromItemId` markers and / or `sourceSubscriptionItemId`. Used
2367
+ // by buildQuoteResult / buildOrderResult to surface a "before" snapshot per
2368
+ // line so the operator can compare current sub state vs. what's about to
2369
+ // change. Returns a map keyed by subscription-item id.
2370
+ async function loadArcSourceMap(
2371
+ em: EntityManager,
2372
+ lineConfigs: Array<{
2373
+ configuration?: Record<string, unknown> | null
2374
+ sourceSubscriptionItemId?: string | null
2375
+ }>,
2376
+ scope: { organizationId: string; tenantId: string },
2377
+ ): Promise<Map<string, CpqInventorySubscriptionItem>> {
2378
+ const ids = new Set<string>()
2379
+ for (const lc of lineConfigs) {
2380
+ const fromMarker = (lc.configuration as Record<string, unknown> | null)?._arcMirroredFromItemId
2381
+ if (typeof fromMarker === 'string') ids.add(fromMarker)
2382
+ if (lc.sourceSubscriptionItemId) ids.add(lc.sourceSubscriptionItemId)
2383
+ }
2384
+ if (ids.size === 0) return new Map()
2385
+
2386
+ const items = await em.find(CpqInventorySubscriptionItem, {
2387
+ id: { $in: Array.from(ids) },
2388
+ organizationId: scope.organizationId,
2389
+ tenantId: scope.tenantId,
2390
+ deletedAt: null,
2391
+ })
2392
+ return new Map(items.map((it) => [it.id, it]))
2393
+ }
2394
+
2395
+ function pickArcSource(
2396
+ lc: {
2397
+ configuration?: Record<string, unknown> | null
2398
+ sourceSubscriptionItemId?: string | null
2399
+ },
2400
+ map: Map<string, CpqInventorySubscriptionItem>,
2401
+ ): ArcLineSource | null {
2402
+ const fromMarker = (lc.configuration as Record<string, unknown> | null)?._arcMirroredFromItemId
2403
+ const id =
2404
+ (typeof fromMarker === 'string' ? fromMarker : null) ?? lc.sourceSubscriptionItemId ?? null
2405
+ if (!id) return null
2406
+ const item = map.get(id)
2407
+ if (!item) return null
2408
+ return {
2409
+ subscriptionItemId: item.id,
2410
+ name: item.name,
2411
+ mrcAmount: Number(item.mrcAmount),
2412
+ nrcAmount: Number(item.nrcAmount),
2413
+ quantity: item.quantity,
2414
+ }
2415
+ }
2416
+
2417
+ // XD-250 ARC renew defaults — when the operator clicks "Renew" we pre-fill
2418
+ // the target row with "same length starting at current term end" so they
2419
+ // don't have to open the drawer just to confirm. Honours an explicit
2420
+ // `input.renewTerm` override and falls back gracefully when the source
2421
+ // subscription has missing `currentTermEnd` / `termMonths` (e.g. fixture
2422
+ // data without dates) — start defaults to today and length to 12 months.
2423
+ function buildRenewDefaults(
2424
+ sub: CpqInventorySubscription,
2425
+ override?: ArcRenewTermInput,
2426
+ ): { start: Date; end: Date; months: number } {
2427
+ if (override) {
2428
+ const start = toDateOrNull(override.newTermStart)
2429
+ const end = toDateOrNull(override.newTermEnd)
2430
+ const months = override.newTermMonths ?? null
2431
+ if (start && end && months != null) return { start, end, months }
2432
+ // Fall through to derived defaults if override was incomplete.
2433
+ }
2434
+ const months = sub.termMonths ?? 12
2435
+ const start = sub.currentTermEnd ? new Date(sub.currentTermEnd) : new Date()
2436
+ const end = new Date(start)
2437
+ end.setMonth(end.getMonth() + months)
2438
+ return { start, end, months }
2439
+ }
2440
+
2441
+ // XD-250 ARC: mirror a subscription's active items as quote lines on the
2442
+ // given quote — used both at quote creation (initial target) and when the
2443
+ // operator attaches additional targets via the modify-subscription drawer.
2444
+ // For amend → action='modify', for cancel → action='cancel'. Preserves
2445
+ // parent-child structure (bundle → components) by pre-generating SalesQuote
2446
+ // line ids before the loop. No-op when the sub has no eligible items.
2447
+ async function mirrorTargetSubscriptionItems(
2448
+ em: EntityManager,
2449
+ cpqConfig: CpqQuoteConfiguration,
2450
+ sub: CpqInventorySubscription,
2451
+ salesQuote: SalesQuote,
2452
+ quoteType: 'amend' | 'cancel' | 'renew',
2453
+ scope: TenantScope,
2454
+ options?: { mergeMode?: boolean },
2455
+ ): Promise<void> {
2456
+ const items = await em.find(CpqInventorySubscriptionItem, {
2457
+ subscriptionId: sub.id,
2458
+ organizationId: scope.organizationId,
2459
+ tenantId: scope.tenantId,
2460
+ deletedAt: null,
2461
+ status: { $nin: ['terminated', 'expired', 'superseded'] },
2462
+ })
2463
+ if (items.length === 0) return
2464
+
2465
+ const existingLines = await em.find(SalesQuoteLine, {
2466
+ quote: salesQuote,
2467
+ organizationId: scope.organizationId,
2468
+ tenantId: scope.tenantId,
2469
+ deletedAt: null,
2470
+ })
2471
+ let nextLineNumber = existingLines.length + 1
2472
+
2473
+ const subItemIdToSalesLineId = new Map<string, string>()
2474
+ for (const item of items) {
2475
+ subItemIdToSalesLineId.set(item.id, crypto.randomUUID())
2476
+ }
2477
+
2478
+ const lineAction: 'modify' | 'cancel' = quoteType === 'cancel' ? 'cancel' : 'modify'
2479
+
2480
+ for (const item of items) {
2481
+ const salesLineId = subItemIdToSalesLineId.get(item.id)!
2482
+ const parentLineId = item.parentItemId
2483
+ ? subItemIdToSalesLineId.get(item.parentItemId) ?? null
2484
+ : null
2485
+
2486
+ const salesLine = em.create(SalesQuoteLine, {
2487
+ id: salesLineId,
2488
+ quote: salesQuote,
2489
+ organizationId: scope.organizationId,
2490
+ tenantId: scope.tenantId,
2491
+ lineNumber: nextLineNumber++,
2492
+ kind: 'product',
2493
+ productId: item.productId ?? null,
2494
+ name: item.name,
2495
+ quantity: String(item.quantity),
2496
+ unitPriceNet: '0',
2497
+ unitPriceGross: '0',
2498
+ totalNetAmount: '0',
2499
+ totalGrossAmount: '0',
2500
+ discountAmount: '0',
2501
+ discountPercent: '0',
2502
+ taxRate: '0',
2503
+ taxAmount: '0',
2504
+ currencyCode: sub.currencyCode,
2505
+ configuration: item.configuration ?? null,
2506
+ normalizedQuantity: '0',
2507
+ createdAt: new Date(),
2508
+ updatedAt: new Date(),
2509
+ } as never)
2510
+ em.persist(salesLine)
2511
+
2512
+ const lineConfig = em.create(CpqQuoteLineConfiguration, {
2513
+ organizationId: scope.organizationId,
2514
+ tenantId: scope.tenantId,
2515
+ quoteLineId: salesLineId,
2516
+ quoteConfigurationId: cpqConfig.id,
2517
+ offeringId: item.offeringId ?? null,
2518
+ productId: item.productId ?? null,
2519
+ specId: item.specId ?? null,
2520
+ action: lineAction,
2521
+ parentLineId,
2522
+ startDate: null,
2523
+ termMonths: null,
2524
+ endDate: null,
2525
+ configuration: {
2526
+ ...item.configuration,
2527
+ _arcMirroredFromItemId: item.id,
2528
+ _arcMirroredName: item.name,
2529
+ },
2530
+ quantity: item.quantity,
2531
+ charges: pickArcMirrorCharges(item, sub.currencyCode),
2532
+ nrcTotal: String(item.nrcAmount ?? 0),
2533
+ mrcTotal: String(item.mrcAmount),
2534
+ isConfigured: true,
2535
+ // XD-250 spec: merge-renew lines must have null `target_subscription_id`
2536
+ // (the source subs are absorbed into a brand-new sub at activation, so
2537
+ // there's no existing item to bind the line to). For amend / cancel /
2538
+ // standalone-renew, bind to the source sub so per-target validation /
2539
+ // groupings work.
2540
+ targetSubscriptionId: options?.mergeMode ? null : sub.id,
2541
+ sourceSubscriptionItemId: item.id,
2542
+ })
2543
+ em.persist(lineConfig)
2544
+ }
2545
+ }
2546
+
2547
+ // XD-250 ARC: when adding a line to an ARC quote, decide which target
2548
+ // subscription the line acts on. Returns null for non-ARC quotes and for
2549
+ // merge-renew quotes (per validateArcQuote: merge-renew lines must have null
2550
+ // target_subscription_id). For amend / cancel / standalone-renew with one
2551
+ // attached target, returns that target. With multiple targets, the caller
2552
+ // must pass an explicit targetSubscriptionId in QuoteItemInput.
2553
+ async function resolveArcLineTargetSubscriptionId(
2554
+ em: EntityManager,
2555
+ cpqConfig: CpqQuoteConfiguration,
2556
+ scope: TenantScope,
2557
+ explicit?: string,
2558
+ ): Promise<string | null> {
2559
+ const quoteType = cpqConfig.quoteType ?? 'new'
2560
+ if (quoteType === 'new') return null
2561
+
2562
+ const targets = await em.find(CpqQuoteTargetSubscription, {
2563
+ quoteId: cpqConfig.id,
2564
+ organizationId: scope.organizationId,
2565
+ tenantId: scope.tenantId,
2566
+ deletedAt: null,
2567
+ })
2568
+ if (targets.length === 0) return null
2569
+
2570
+ const isMerge =
2571
+ quoteType === 'renew' &&
2572
+ targets.length >= 2 &&
2573
+ targets.every((t) => t.mergeAction === 'absorb')
2574
+ if (isMerge) return null
2575
+
2576
+ if (explicit) {
2577
+ if (!targets.some((t) => t.subscriptionId === explicit)) {
2578
+ throw new QuotingError(
2579
+ 422,
2580
+ `targetSubscriptionId '${explicit}' is not attached to this quote`,
2581
+ )
2582
+ }
2583
+ return explicit
2584
+ }
2585
+
2586
+ if (targets.length === 1) return targets[0].subscriptionId
2587
+
2588
+ throw new QuotingError(
2589
+ 422,
2590
+ `Quote has ${targets.length} attached targets — specify targetSubscriptionId on the line`,
2591
+ )
2592
+ }
2593
+
2594
+ // Build the per-instance charges JSON for an ARC-mirrored quote line. If the
2595
+ // source subscription item already has explicit charges, copy them. Otherwise
2596
+ // synthesize a flat MRC / NRC charge from the item's stored amounts so that
2597
+ // recalculate (which scales totalPrice by lineConfig.quantity) has something
2598
+ // to work with — without this, changing quantity on a mirrored line wipes the
2599
+ // charges array and the line flips to "incomplete".
2600
+ function pickArcMirrorCharges(
2601
+ item: { mrcAmount: string | number; nrcAmount?: string | number; quantity: number; charges?: unknown },
2602
+ currencyCode: string,
2603
+ ): Array<Record<string, unknown>> | null {
2604
+ const existing = (item.charges ?? null) as Array<Record<string, unknown>> | null
2605
+ if (existing && existing.length > 0) return [...existing]
2606
+
2607
+ const sourceQty = Math.max(1, Number(item.quantity) || 1)
2608
+ const mrc = Number(item.mrcAmount) || 0
2609
+ const nrc = Number(item.nrcAmount ?? 0) || 0
2610
+ const synth: Array<Record<string, unknown>> = []
2611
+ if (mrc > 0) {
2612
+ const perInstance = mrc / sourceQty
2613
+ synth.push({
2614
+ chargeCode: 'mrc',
2615
+ chargeName: 'Monthly Recurring',
2616
+ chargeType: 'mrc',
2617
+ pricingMethod: 'flat',
2618
+ unitPrice: perInstance,
2619
+ quantity: 1,
2620
+ totalPrice: perInstance,
2621
+ currencyCode,
2622
+ })
2623
+ }
2624
+ if (nrc > 0) {
2625
+ const perInstance = nrc / sourceQty
2626
+ synth.push({
2627
+ chargeCode: 'nrc',
2628
+ chargeName: 'Non-Recurring',
2629
+ chargeType: 'nrc',
2630
+ pricingMethod: 'flat',
2631
+ unitPrice: perInstance,
2632
+ quantity: 1,
2633
+ totalPrice: perInstance,
2634
+ currencyCode,
2635
+ })
2636
+ }
2637
+ return synth.length > 0 ? synth : null
2638
+ }