@dainamite/cpq 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/MIGRATION.md +74 -0
  3. package/dist/modules/cpq/acl.js +1 -0
  4. package/dist/modules/cpq/acl.js.map +2 -2
  5. package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js +45 -0
  6. package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js.map +7 -0
  7. package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js +23 -0
  8. package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js.map +7 -0
  9. package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js +19 -0
  10. package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js.map +7 -0
  11. package/dist/modules/cpq/api/orders/[orderId]/route.js +17 -1
  12. package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
  13. package/dist/modules/cpq/api/orders/route.js +47 -2
  14. package/dist/modules/cpq/api/orders/route.js.map +2 -2
  15. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js +54 -0
  16. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js.map +7 -0
  17. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +42 -2
  18. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
  19. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -2
  20. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +2 -2
  21. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js +47 -0
  22. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js.map +7 -0
  23. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js +30 -0
  24. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js.map +7 -0
  25. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js +47 -0
  26. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js.map +7 -0
  27. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +32 -2
  28. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
  29. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +33 -2
  30. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +2 -2
  31. package/dist/modules/cpq/api/quotes/price/route.js +43 -3
  32. package/dist/modules/cpq/api/quotes/price/route.js.map +2 -2
  33. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js +3 -9
  34. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +2 -2
  35. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js +21 -25
  36. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +2 -2
  37. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js +48 -47
  38. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js.map +2 -2
  39. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js +1 -1
  40. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js.map +2 -2
  41. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +3 -3
  42. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +2 -2
  43. package/dist/modules/cpq/backend/cpq/inventory/shared.js +37 -2
  44. package/dist/modules/cpq/backend/cpq/inventory/shared.js.map +2 -2
  45. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +174 -184
  46. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
  47. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js +1 -1
  48. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js.map +2 -2
  49. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +168 -143
  50. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
  51. package/dist/modules/cpq/backend/cpq/offerings/page.js +36 -34
  52. package/dist/modules/cpq/backend/cpq/offerings/page.js.map +2 -2
  53. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +140 -280
  54. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
  55. package/dist/modules/cpq/backend/cpq/orders/page.js +6 -6
  56. package/dist/modules/cpq/backend/cpq/orders/page.js.map +2 -2
  57. package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
  58. package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +1 -1
  59. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js +41 -36
  60. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js.map +2 -2
  61. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js +76 -72
  62. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js.map +2 -2
  63. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js +1 -1
  64. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js.map +2 -2
  65. package/dist/modules/cpq/backend/cpq/price-rules/page.js +18 -15
  66. package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +2 -2
  67. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +23 -21
  68. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
  69. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js +22 -50
  70. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js.map +2 -2
  71. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js +74 -52
  72. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js.map +2 -2
  73. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js +20 -17
  74. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js.map +2 -2
  75. package/dist/modules/cpq/backend/cpq/pricing/page.js +1 -1
  76. package/dist/modules/cpq/backend/cpq/pricing/page.js.map +2 -2
  77. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js +115 -123
  78. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js.map +2 -2
  79. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +208 -0
  80. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
  81. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
  82. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
  83. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
  84. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
  85. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
  86. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
  87. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
  88. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
  89. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +269 -311
  90. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
  91. package/dist/modules/cpq/backend/cpq/quotes/page.js +23 -19
  92. package/dist/modules/cpq/backend/cpq/quotes/page.js.map +2 -2
  93. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
  94. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +1 -1
  95. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js +19 -15
  96. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js.map +2 -2
  97. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +171 -178
  98. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
  99. package/dist/modules/cpq/backend/cpq/specifications/page.js +2 -2
  100. package/dist/modules/cpq/backend/cpq/specifications/page.js.map +2 -2
  101. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js +60 -50
  102. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js.map +2 -2
  103. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js +22 -18
  104. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js.map +2 -2
  105. package/dist/modules/cpq/backend/cpq/wizards/page.js +1 -1
  106. package/dist/modules/cpq/backend/cpq/wizards/page.js.map +2 -2
  107. package/dist/modules/cpq/ce.js +8 -0
  108. package/dist/modules/cpq/ce.js.map +2 -2
  109. package/dist/modules/cpq/cli.js +34 -1
  110. package/dist/modules/cpq/cli.js.map +2 -2
  111. package/dist/modules/cpq/commands/price-adjustments.js +107 -0
  112. package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
  113. package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
  114. package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
  115. package/dist/modules/cpq/components/ChargeBreakdownTable.js +148 -0
  116. package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
  117. package/dist/modules/cpq/components/PriceWaterfall.js +68 -0
  118. package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
  119. package/dist/modules/cpq/components/PricingSummaryCard.js +112 -0
  120. package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
  121. package/dist/modules/cpq/components/StatusPath.js +5 -3
  122. package/dist/modules/cpq/components/StatusPath.js.map +2 -2
  123. package/dist/modules/cpq/components/useCpqRowActions.js +10 -22
  124. package/dist/modules/cpq/components/useCpqRowActions.js.map +2 -2
  125. package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
  126. package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
  127. package/dist/modules/cpq/data/entities.js +97 -0
  128. package/dist/modules/cpq/data/entities.js.map +2 -2
  129. package/dist/modules/cpq/data/price-adjustments.js +70 -0
  130. package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
  131. package/dist/modules/cpq/data/validators.js +4 -0
  132. package/dist/modules/cpq/data/validators.js.map +2 -2
  133. package/dist/modules/cpq/di.js +6 -0
  134. package/dist/modules/cpq/di.js.map +2 -2
  135. package/dist/modules/cpq/events.js +47 -0
  136. package/dist/modules/cpq/events.js.map +2 -2
  137. package/dist/modules/cpq/i18n/de.json +1220 -0
  138. package/dist/modules/cpq/i18n/en.json +1220 -0
  139. package/dist/modules/cpq/i18n/es.json +1220 -0
  140. package/dist/modules/cpq/i18n/pl.json +1220 -0
  141. package/dist/modules/cpq/index.js +1 -1
  142. package/dist/modules/cpq/index.js.map +2 -2
  143. package/dist/modules/cpq/lib/auditActor.js +10 -0
  144. package/dist/modules/cpq/lib/auditActor.js.map +7 -0
  145. package/dist/modules/cpq/lib/formatMoney.js +7 -0
  146. package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
  147. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
  148. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
  149. package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
  150. package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
  151. package/dist/modules/cpq/lib/quotePermissions.js +13 -0
  152. package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
  153. package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
  154. package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
  155. package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
  156. package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
  157. package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
  158. package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
  159. package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
  160. package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
  161. package/dist/modules/cpq/services/cpqOrderService.js +25 -28
  162. package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
  163. package/dist/modules/cpq/services/cpqPricingService.js +7 -0
  164. package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
  165. package/dist/modules/cpq/services/cpqQuotingService.js +330 -109
  166. package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
  167. package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
  168. package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
  169. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
  170. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
  171. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
  172. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
  173. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
  174. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
  175. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
  176. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
  177. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
  178. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
  179. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
  180. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
  181. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
  182. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
  183. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
  184. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
  185. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
  186. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
  187. package/dist/modules/cpq/services/types.js.map +2 -2
  188. package/dist/modules/cpq/setup.js +6 -0
  189. package/dist/modules/cpq/setup.js.map +2 -2
  190. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js +39 -31
  191. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js.map +2 -2
  192. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js +13 -5
  193. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js.map +2 -2
  194. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js +41 -21
  195. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js.map +2 -2
  196. package/dist/modules/cpq/widgets/injection-table.js +2 -2
  197. package/dist/modules/cpq/widgets/injection-table.js.map +2 -2
  198. package/dist/modules/cpq/workflows/WizardRunner.js +10 -8
  199. package/dist/modules/cpq/workflows/WizardRunner.js.map +2 -2
  200. package/dist/modules/cpq/workflows/WizardTrigger.js +15 -9
  201. package/dist/modules/cpq/workflows/WizardTrigger.js.map +2 -2
  202. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js +9 -7
  203. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js.map +2 -2
  204. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js +9 -4
  205. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js.map +2 -2
  206. package/dist/modules/cpq/workflows/steps/ItemListStep.js +16 -19
  207. package/dist/modules/cpq/workflows/steps/ItemListStep.js.map +2 -2
  208. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js +19 -32
  209. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js.map +2 -2
  210. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js +8 -6
  211. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js.map +2 -2
  212. package/dist/modules/cpq/workflows/steps/ReviewStep.js +32 -30
  213. package/dist/modules/cpq/workflows/steps/ReviewStep.js.map +2 -2
  214. package/dist/modules/cpq/workflows/steps/index.js +12 -0
  215. package/dist/modules/cpq/workflows/steps/index.js.map +2 -2
  216. package/package.json +1 -1
  217. package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
  218. package/src/modules/cpq/__tests__/i18n/dictionaries.test.ts +62 -0
  219. package/src/modules/cpq/__tests__/i18n/hardcodedStrings.test.ts +35 -0
  220. package/src/modules/cpq/__tests__/i18n/sourceScan.ts +109 -0
  221. package/src/modules/cpq/acl.ts +1 -0
  222. package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
  223. package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
  224. package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
  225. package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
  226. package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
  227. package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
  228. package/src/modules/cpq/api/orders/[orderId]/route.ts +18 -0
  229. package/src/modules/cpq/api/orders/route.ts +51 -1
  230. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
  231. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +48 -1
  232. package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
  233. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
  234. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
  235. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
  236. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
  237. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +40 -1
  238. package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
  239. package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
  240. package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
  241. package/src/modules/cpq/api/quotes/price/route.ts +28 -2
  242. package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +3 -9
  243. package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +22 -26
  244. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.ts +1 -1
  245. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.tsx +43 -46
  246. package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +3 -3
  247. package/src/modules/cpq/backend/cpq/inventory/shared.tsx +37 -1
  248. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.ts +1 -1
  249. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +164 -120
  250. package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +169 -130
  251. package/src/modules/cpq/backend/cpq/offerings/page.tsx +34 -24
  252. package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +122 -308
  253. package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
  254. package/src/modules/cpq/backend/cpq/orders/page.tsx +6 -6
  255. package/src/modules/cpq/backend/cpq/price-rules/PriceRuleForm.tsx +48 -34
  256. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.meta.ts +1 -1
  257. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.tsx +76 -70
  258. package/src/modules/cpq/backend/cpq/price-rules/page.tsx +25 -16
  259. package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -18
  260. package/src/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.tsx +29 -19
  261. package/src/modules/cpq/backend/cpq/pricing/[id]/page.tsx +95 -47
  262. package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.ts +23 -15
  263. package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
  264. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.tsx +111 -88
  265. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +260 -0
  266. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
  267. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
  268. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
  269. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
  270. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
  271. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +304 -348
  272. package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
  273. package/src/modules/cpq/backend/cpq/quotes/page.tsx +29 -21
  274. package/src/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.tsx +19 -15
  275. package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +171 -171
  276. package/src/modules/cpq/backend/cpq/specifications/page.tsx +2 -2
  277. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.tsx +51 -41
  278. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/page.tsx +17 -13
  279. package/src/modules/cpq/backend/cpq/wizards/page.tsx +1 -1
  280. package/src/modules/cpq/ce.ts +8 -0
  281. package/src/modules/cpq/cli.ts +48 -1
  282. package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
  283. package/src/modules/cpq/commands/price-adjustments.ts +90 -0
  284. package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
  285. package/src/modules/cpq/components/ChargeBreakdownTable.tsx +203 -0
  286. package/src/modules/cpq/components/PriceWaterfall.tsx +96 -0
  287. package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
  288. package/src/modules/cpq/components/StatusPath.tsx +7 -4
  289. package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
  290. package/src/modules/cpq/components/useCpqRowActions.tsx +17 -25
  291. package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
  292. package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
  293. package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
  294. package/src/modules/cpq/data/entities.ts +83 -0
  295. package/src/modules/cpq/data/price-adjustments.ts +67 -0
  296. package/src/modules/cpq/data/validators.ts +3 -0
  297. package/src/modules/cpq/di.ts +6 -0
  298. package/src/modules/cpq/events.ts +53 -2
  299. package/src/modules/cpq/i18n/de.json +1220 -0
  300. package/src/modules/cpq/i18n/en.json +1220 -0
  301. package/src/modules/cpq/i18n/es.json +1220 -0
  302. package/src/modules/cpq/i18n/pl.json +1220 -0
  303. package/src/modules/cpq/index.ts +1 -1
  304. package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
  305. package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
  306. package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
  307. package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
  308. package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
  309. package/src/modules/cpq/lib/auditActor.ts +13 -0
  310. package/src/modules/cpq/lib/formatMoney.ts +4 -0
  311. package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
  312. package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
  313. package/src/modules/cpq/lib/quotePermissions.ts +22 -0
  314. package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
  315. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
  316. package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
  317. package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
  318. package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
  319. package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
  320. package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
  321. package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
  322. package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
  323. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
  324. package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
  325. package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
  326. package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
  327. package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
  328. package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
  329. package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
  330. package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
  331. package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
  332. package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
  333. package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
  334. package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
  335. package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
  336. package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
  337. package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
  338. package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
  339. package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
  340. package/src/modules/cpq/services/cpqOrderService.ts +22 -28
  341. package/src/modules/cpq/services/cpqPricingService.ts +8 -0
  342. package/src/modules/cpq/services/cpqQuotingService.ts +374 -125
  343. package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
  344. package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
  345. package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
  346. package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
  347. package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
  348. package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
  349. package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
  350. package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
  351. package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
  352. package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
  353. package/src/modules/cpq/services/types.ts +34 -1
  354. package/src/modules/cpq/setup.ts +7 -1
  355. package/src/modules/cpq/widgets/injection/customer-inventory/widget.client.tsx +38 -31
  356. package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.client.tsx +7 -5
  357. package/src/modules/cpq/widgets/injection/quote-configurator/widget.client.tsx +36 -21
  358. package/src/modules/cpq/widgets/injection-table.ts +8 -2
  359. package/src/modules/cpq/workflows/WizardRunner.tsx +8 -5
  360. package/src/modules/cpq/workflows/WizardTrigger.tsx +15 -9
  361. package/src/modules/cpq/workflows/steps/CustomerSelectStep.tsx +11 -7
  362. package/src/modules/cpq/workflows/steps/InventorySelectStep.tsx +9 -5
  363. package/src/modules/cpq/workflows/steps/ItemListStep.tsx +18 -12
  364. package/src/modules/cpq/workflows/steps/OfferingSelectStep.tsx +20 -16
  365. package/src/modules/cpq/workflows/steps/ProductConfigureStep.tsx +8 -6
  366. package/src/modules/cpq/workflows/steps/ReviewStep.tsx +33 -18
  367. package/src/modules/cpq/workflows/steps/index.ts +12 -0
  368. package/src/modules/cpq/workflows/types.ts +4 -0
@@ -0,0 +1,47 @@
1
+ import { computeWaterfallSummary } from './priceAdjustments/pricingSnapshot'
2
+ import { isArcMirrorLine } from './arcPriceFreeze'
3
+ import type { PricingWaterfallSummary, ResolvedCharge } from './types'
4
+
5
+ export type OrderSummaryLine = {
6
+ lineId: string
7
+ quantity: number
8
+ termMonths: number | null
9
+ configuration: Record<string, unknown> | null
10
+ charges: Array<Record<string, unknown>>
11
+ }
12
+
13
+ /** The quote's list / standard / offer figures and deal value, minus quote-only fields. */
14
+ export type OrderWaterfallSummary = Omit<PricingWaterfallSummary, 'snapshotHash' | 'hasTimeBasedAdjustments'>
15
+
16
+ /** Order lines store the quote's charges as plain JSON; fill the fields the waterfall needs. */
17
+ function toResolvedCharge(charge: Record<string, unknown>): ResolvedCharge {
18
+ return {
19
+ ...charge,
20
+ chargeCode: String(charge.chargeCode ?? ''),
21
+ chargeType: (charge.chargeType ?? 'mrc') as ResolvedCharge['chargeType'],
22
+ unitPrice: Number(charge.unitPrice ?? 0),
23
+ totalPrice: charge.totalPrice == null ? null : Number(charge.totalPrice),
24
+ } as ResolvedCharge
25
+ }
26
+
27
+ /**
28
+ * The same pricing summary the source quote shows (list totals, discount
29
+ * depth, deal value), computed from the order's line charges, which are
30
+ * copied verbatim from the quote at conversion. ARC mirror lines are left out
31
+ * of deal value exactly as on the quote.
32
+ */
33
+ export function summarizeOrderPricing(
34
+ lines: readonly OrderSummaryLine[],
35
+ quoteType: string,
36
+ currencyCode: string,
37
+ ): OrderWaterfallSummary {
38
+ const summary = computeWaterfallSummary(lines.map((line) => ({
39
+ lineId: line.lineId,
40
+ quantity: line.quantity,
41
+ termMonths: line.termMonths,
42
+ includeInDeal: !isArcMirrorLine(line.configuration, quoteType),
43
+ charges: line.charges.map(toResolvedCharge),
44
+ })), [], currencyCode)
45
+ const { snapshotHash: _snapshotHash, hasTimeBasedAdjustments: _timeBased, ...orderSummary } = summary
46
+ return orderSummary
47
+ }
@@ -0,0 +1,249 @@
1
+ import { isArcMirrorLine } from '../arcPriceFreeze'
2
+ import { ADJUSTMENT_TOGGLE, REASON_DICTIONARY } from '../../data/price-adjustments'
3
+ export { ADJUSTMENT_TOGGLE, REASON_DICTIONARY } from '../../data/price-adjustments'
4
+ import { loadQuoteAdjustments } from './loadQuoteAdjustments'
5
+ import { randomUUID } from 'node:crypto'
6
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
+ import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
9
+ import type { FeatureTogglesService } from '@open-mercato/core/modules/feature_toggles/lib/feature-flag-check'
10
+ import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
11
+ import { CpqOrderConfiguration, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
12
+ import { cpqPriceAdjustmentInputSchema, cpqPriceAdjustmentPatchSchema, type PriceAdjustmentInput, type PriceAdjustmentPatch } from '../../data/price-adjustments'
13
+ import { QuotingError, type DefaultCpqQuotingService } from '../cpqQuotingService'
14
+ import { TERMINAL_STATUSES, type CpqStatus, type QuoteResult, type TenantScope } from '../types'
15
+ import { withQuotePricingWrite } from './quotePricingWrite'
16
+
17
+ /** Quote statuses in which reps may still add / edit / delete manual adjustments. */
18
+ const ADJUSTMENT_MUTABLE_STATUSES: readonly CpqStatus[] = ['new', 'incomplete', 'ready']
19
+
20
+
21
+ type Dependencies = {
22
+ em: EntityManager
23
+ quotingService: DefaultCpqQuotingService
24
+ featureTogglesService: Pick<FeatureTogglesService, 'getBoolConfig'>
25
+ }
26
+
27
+ export function adjustmentInput(row: CpqQuotePriceAdjustment): PriceAdjustmentInput {
28
+ return cpqPriceAdjustmentInputSchema.parse({ level: row.level, quoteLineId: row.quoteLineId,
29
+ chargeCode: row.chargeCode, chargeTypeFilter: row.chargeTypeFilter, adjustmentType: row.adjustmentType,
30
+ value: Number(row.value), currencyCode: row.currencyCode, reasonCode: row.reasonCode, reasonNote: row.reasonNote })
31
+ }
32
+ export function adjustmentSnapshot(row: CpqQuotePriceAdjustment) {
33
+ return { id: row.id, ...adjustmentInput(row), quoteConfigurationId: row.quoteConfigurationId,
34
+ quoteId: row.quoteId, tenantId: row.tenantId, organizationId: row.organizationId,
35
+ createdByUserId: row.createdByUserId, createdAt: row.createdAt.toISOString(),
36
+ updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null, isActive: row.isActive }
37
+ }
38
+ export type AdjustmentSnapshot = ReturnType<typeof adjustmentSnapshot>
39
+
40
+ type OverlapKey = { id: string; level: string; quoteLineId: string; chargeCode?: string | null; chargeTypeFilter?: string | null }
41
+ /** Same level and target; line filters overlap when either covers all charges or both match. */
42
+ export function adjustmentsOverlap(a: OverlapKey, b: OverlapKey): boolean {
43
+ if (a.id === b.id || a.level !== b.level || a.quoteLineId !== b.quoteLineId) return false
44
+ if (a.level === 'charge') return (a.chargeCode ?? null) === (b.chargeCode ?? null)
45
+ return !a.chargeTypeFilter || !b.chargeTypeFilter || a.chargeTypeFilter === b.chargeTypeFilter
46
+ }
47
+ export interface AdjustmentWriteResult {
48
+ adjustment: AdjustmentSnapshot
49
+ before: AdjustmentSnapshot | null
50
+ quote: QuoteResult
51
+ }
52
+
53
+ export class CpqAdjustmentService {
54
+ constructor(private readonly deps: Dependencies) {}
55
+
56
+ async enabled(tenantId: string): Promise<boolean> {
57
+ const flag = await this.deps.featureTogglesService.getBoolConfig(ADJUSTMENT_TOGGLE, tenantId)
58
+ return flag.ok && flag.value === true
59
+ }
60
+
61
+ async list(quoteId: string, scope: TenantScope, page: number, pageSize: number) {
62
+ const em = this.deps.em.fork({ clear: true, useContext: false })
63
+ const scoped = { ...scope, deletedAt: null }
64
+ const quote = await findOneWithDecryption(em, CpqQuoteConfiguration, { ...scoped, id: quoteId }, undefined, scope)
65
+ ?? await findOneWithDecryption(em, CpqQuoteConfiguration, { ...scoped, quoteId }, undefined, scope)
66
+ if (!quote) throw new QuotingError(404, 'Quote not found')
67
+ const filter = { ...scoped, quoteConfigurationId: quote.id, isActive: true }
68
+ const [rows, total, enabled, reasons] = await Promise.all([
69
+ findWithDecryption(em, CpqQuotePriceAdjustment, filter, { limit: pageSize, offset: (page - 1) * pageSize, orderBy: { createdAt: 'asc', id: 'asc' } }, scope),
70
+ em.count(CpqQuotePriceAdjustment, filter),
71
+ this.enabled(scope.tenantId),
72
+ this.reasons(em, scope),
73
+ ])
74
+ return { items: rows.map(adjustmentSnapshot), total, page, pageSize, totalPages: Math.ceil(total / pageSize), pricingRevision: quote.pricingRevision ?? 0, enabled, reasons }
75
+ }
76
+
77
+ /**
78
+ * Reason options for the adjustment dialog. Served here so reps need only
79
+ * `cpq.quotes.view`, not `dictionaries.view`. An inactive dictionary yields
80
+ * no options, matching `requireReason`.
81
+ */
82
+ async reasons(em: EntityManager, scope: TenantScope): Promise<Array<{ value: string; label: string }>> {
83
+ const dictionary = await findOneWithDecryption(em, Dictionary, { key: REASON_DICTIONARY, ...scope, deletedAt: null, isActive: true }, undefined, scope)
84
+ if (!dictionary) return []
85
+ const entries = await findWithDecryption(em, DictionaryEntry, { dictionary: dictionary.id, ...scope }, { orderBy: { position: 'asc', label: 'asc' } }, scope)
86
+ return entries.map((entry) => ({ value: entry.value, label: entry.label }))
87
+ }
88
+
89
+ async preview(quoteId: string, input: PriceAdjustmentInput, replaceAdjustmentId: string | undefined, scope: TenantScope) {
90
+ const parsed = cpqPriceAdjustmentInputSchema.parse(input)
91
+ await this.requireEnabled(scope)
92
+ const em = this.deps.em.fork({ clear: true, useContext: false })
93
+ const scoped = { ...scope, deletedAt: null }
94
+ const quote = await findOneWithDecryption(em, CpqQuoteConfiguration, { ...scoped, id: quoteId }, undefined, scope)
95
+ ?? await findOneWithDecryption(em, CpqQuoteConfiguration, { ...scoped, quoteId }, undefined, scope)
96
+ if (!quote) throw new QuotingError(404, 'Quote not found')
97
+ if (parsed.expectedRevision !== undefined && parsed.expectedRevision !== (quote.pricingRevision ?? 0)) throw new QuotingError(409, 'STALE_REVISION')
98
+ await this.assertMutable(em, quote, scope)
99
+ await this.requireTarget(em, quote, parsed.quoteLineId, scope)
100
+ const stored = await loadQuoteAdjustments(em, quote.id, scope)
101
+ const replaced = stored.find((row) => row.id === replaceAdjustmentId)
102
+ if (replaceAdjustmentId && !replaced) throw new QuotingError(404, 'Adjustment not found')
103
+ if (replaced && (replaced.level !== parsed.level || replaced.quoteLineId !== parsed.quoteLineId || (replaced.chargeCode ?? null) !== (parsed.chargeCode ?? null))) throw new QuotingError(422, 'Adjustment target is immutable')
104
+ if (!replaced || replaced.reasonCode !== parsed.reasonCode) await this.requireReason(em, parsed.reasonCode, scope)
105
+ const draft = { ...parsed, id: replaceAdjustmentId ?? randomUUID() }
106
+ const adjustments = [...stored.filter((row) => row.id !== replaceAdjustmentId), draft]
107
+ const result = await this.deps.quotingService.recalculateLockedQuote(em, quote, scope, false, adjustments)
108
+ return { quote: result, errors: result.adjustmentErrors ?? [] }
109
+ }
110
+
111
+ async create(quoteId: string, input: PriceAdjustmentInput, actor: string, scope: TenantScope): Promise<AdjustmentWriteResult> {
112
+ const parsed = cpqPriceAdjustmentInputSchema.parse(input)
113
+ await this.requireEnabled(scope)
114
+ return this.write(quoteId, scope, parsed.expectedRevision, async (em, quote) => {
115
+ await this.assertMutable(em, quote, scope)
116
+ await this.requireReason(em, parsed.reasonCode, scope)
117
+ await this.requireTarget(em, quote, parsed.quoteLineId, scope)
118
+ const id = randomUUID()
119
+ await this.rejectOverlap(em, quote, { id, ...parsed }, scope)
120
+ const row = em.create(CpqQuotePriceAdjustment, { ...scope, id, quoteConfigurationId: quote.id,
121
+ quoteId: quote.quoteId, ...this.values(parsed), createdByUserId: actor })
122
+ return this.save(em, quote, row, null, scope, true)
123
+ })
124
+ }
125
+
126
+ async update(quoteId: string, id: string, patch: PriceAdjustmentPatch, actor: string, scope: TenantScope): Promise<AdjustmentWriteResult> {
127
+ const parsed = cpqPriceAdjustmentPatchSchema.parse(patch)
128
+ await this.requireEnabled(scope)
129
+ return this.write(quoteId, scope, parsed.expectedRevision, async (em, quote) => {
130
+ await this.assertMutable(em, quote, scope)
131
+ const row = await this.loadRow(em, quote.id, id, scope)
132
+ const before = adjustmentSnapshot(row)
133
+ await this.requireTarget(em, quote, row.quoteLineId, scope)
134
+ const merged = cpqPriceAdjustmentInputSchema.parse({ ...adjustmentInput(row), ...parsed })
135
+ if (merged.reasonCode !== row.reasonCode) await this.requireReason(em, merged.reasonCode, scope)
136
+ await this.rejectOverlap(em, quote, { id: row.id, ...merged }, scope)
137
+ em.assign(row, { ...this.values(merged), updatedByUserId: actor, updatedAt: new Date() })
138
+ return this.save(em, quote, row, before, scope, true)
139
+ })
140
+ }
141
+
142
+ async remove(quoteId: string, id: string, actor: string, scope: TenantScope, expectedRevision?: number): Promise<AdjustmentWriteResult> {
143
+ await this.requireEnabled(scope)
144
+ return this.write(quoteId, scope, expectedRevision, async (em, quote) => {
145
+ const row = await this.loadRow(em, quote.id, id, scope)
146
+ const preview = await this.deps.quotingService.recalculateLockedQuote(em, quote, scope, false)
147
+ const invalid = preview.adjustmentErrors?.some((error) => error.adjustmentId === id) ?? false
148
+ await this.assertMutable(em, quote, scope, invalid)
149
+ const before = adjustmentSnapshot(row)
150
+ row.deletedAt = new Date(); row.isActive = false; row.updatedByUserId = actor; row.updatedAt = new Date()
151
+ return this.save(em, quote, row, before, scope, false)
152
+ })
153
+ }
154
+
155
+ /** Restores an audited previous row under the same quote lock; never reopens a locked quote. */
156
+ async undo(quoteId: string, id: string, before: AdjustmentSnapshot | null, after: AdjustmentSnapshot, actor: string, scope: TenantScope): Promise<void> {
157
+ await this.requireEnabled(scope)
158
+ await this.write(quoteId, scope, undefined, async (em, quote) => {
159
+ await this.assertMutable(em, quote, scope)
160
+ for (const snapshot of [before, after]) {
161
+ if (snapshot && (snapshot.tenantId !== scope.tenantId || snapshot.organizationId !== scope.organizationId || snapshot.quoteConfigurationId !== quote.id || snapshot.id !== id)) {
162
+ throw new QuotingError(403, 'Adjustment undo scope mismatch')
163
+ }
164
+ }
165
+ const row = await this.loadRow(em, quote.id, id, scope, true)
166
+ if (snapshotKey(adjustmentSnapshot(row)) !== snapshotKey(after)) throw new QuotingError(409, 'Adjustment changed since this action')
167
+ const restored = before ? cpqPriceAdjustmentInputSchema.parse(this.inputFromSnapshot(before)) : null
168
+ // Restoring an active row must not create an overlap a rep added since.
169
+ if (restored && before?.isActive && !before.deletedAt) await this.rejectOverlap(em, quote, { id, ...restored }, scope)
170
+ const beforeUndo = adjustmentSnapshot(row)
171
+ if (before && restored) em.assign(row, { ...this.values(restored),
172
+ deletedAt: before.deletedAt ? new Date(before.deletedAt) : null, isActive: before.isActive, updatedByUserId: actor, updatedAt: new Date() })
173
+ else { row.deletedAt = new Date(); row.isActive = false; row.updatedByUserId = actor; row.updatedAt = new Date() }
174
+ await this.save(em, quote, row, beforeUndo, scope, false)
175
+ })
176
+ }
177
+
178
+ private inputFromSnapshot(snapshot: AdjustmentSnapshot): PriceAdjustmentInput {
179
+ const { id, quoteConfigurationId, quoteId, tenantId, organizationId, createdByUserId, createdAt, updatedAt, deletedAt, isActive, ...input } = snapshot
180
+ return input
181
+ }
182
+
183
+ private values(input: PriceAdjustmentInput) {
184
+ return { level: input.level, quoteLineId: input.quoteLineId, chargeCode: input.chargeCode ?? null,
185
+ chargeTypeFilter: input.chargeTypeFilter ?? null, adjustmentType: input.adjustmentType, value: String(input.value),
186
+ currencyCode: input.currencyCode ?? null, reasonCode: input.reasonCode, reasonNote: input.reasonNote ?? null }
187
+ }
188
+
189
+ private write<T>(quoteId: string, scope: TenantScope, revision: number | undefined, execute: (em: EntityManager, quote: CpqQuoteConfiguration) => Promise<T>) {
190
+ return withQuotePricingWrite(this.deps.em, quoteId, scope, execute, (status, message) => new QuotingError(status, message), revision, undefined, { pricingCause: 'adjustment' })
191
+ }
192
+
193
+ private async save(em: EntityManager, quote: CpqQuoteConfiguration, row: CpqQuotePriceAdjustment, before: AdjustmentSnapshot | null, scope: TenantScope, rejectInvalid: boolean): Promise<AdjustmentWriteResult> {
194
+ let priced: QuoteResult | undefined
195
+ await withAtomicFlush(em, [
196
+ () => { em.persist(row) },
197
+ async () => { priced = await this.deps.quotingService.recalculateLockedQuote(em, quote, scope) },
198
+ ])
199
+ if (!priced) throw new Error('Adjustment recalculation did not return a quote')
200
+ const invalid = priced.adjustmentErrors?.find((error) => error.adjustmentId === row.id)
201
+ if (rejectInvalid && invalid) throw new QuotingError(422, invalid.code, undefined, { code: invalid.code, adjustmentId: row.id })
202
+ return { adjustment: adjustmentSnapshot(row), before, quote: priced }
203
+ }
204
+
205
+ private async requireEnabled(scope: TenantScope) {
206
+ if (!(await this.enabled(scope.tenantId))) throw new QuotingError(403, 'Manual price adjustments are disabled')
207
+ }
208
+
209
+ private async requireTarget(em: EntityManager, quote: CpqQuoteConfiguration, quoteLineId: string, scope: TenantScope) {
210
+ const line = await findOneWithDecryption(em, CpqQuoteLineConfiguration, { quoteConfigurationId: quote.id, quoteLineId, ...scope, deletedAt: null }, undefined, scope)
211
+ if (!line) throw new QuotingError(404, 'Quote line not found')
212
+ if (!(line.offeringId || line.productId) || isArcMirrorLine(line.configuration, quote.quoteType)) throw new QuotingError(422, 'LINE_NOT_ELIGIBLE')
213
+ }
214
+
215
+ /**
216
+ * Refuse overlaps against every stored row, valid or not: an overlap hidden
217
+ * behind a currently-invalid row would start applying once that row is
218
+ * deleted, changing the price of a submitted quote. Runs before any mutation.
219
+ */
220
+ private async rejectOverlap(em: EntityManager, quote: CpqQuoteConfiguration, candidate: Parameters<typeof adjustmentsOverlap>[0], scope: TenantScope) {
221
+ const stored = await loadQuoteAdjustments(em, quote.id, scope)
222
+ if (stored.some((other) => adjustmentsOverlap(candidate, other))) {
223
+ throw new QuotingError(422, 'ADJUSTMENT_OVERLAP', undefined, { code: 'ADJUSTMENT_OVERLAP', adjustmentId: candidate.id })
224
+ }
225
+ }
226
+
227
+ private async requireReason(em: EntityManager, code: string, scope: TenantScope) {
228
+ const dictionary = await findOneWithDecryption(em, Dictionary, { key: REASON_DICTIONARY, ...scope, deletedAt: null, isActive: true }, undefined, scope)
229
+ const entry = dictionary && await findOneWithDecryption(em, DictionaryEntry, { dictionary: dictionary.id, ...scope, value: code }, undefined, scope)
230
+ if (!entry) throw new QuotingError(422, 'REASON_NOT_FOUND')
231
+ }
232
+
233
+ private async assertMutable(em: EntityManager, quote: CpqQuoteConfiguration, scope: TenantScope, invalidDelete = false) {
234
+ const order = await findOneWithDecryption(em, CpqOrderConfiguration, { sourceQuoteId: quote.id, ...scope, deletedAt: null }, undefined, scope)
235
+ const allowed = ADJUSTMENT_MUTABLE_STATUSES.includes(quote.cpqStatus as CpqStatus)
236
+ const invalidException = invalidDelete && !TERMINAL_STATUSES.includes(quote.cpqStatus as CpqStatus)
237
+ if (order || (!allowed && !invalidException)) throw new QuotingError(409, 'ADJUSTMENT_LOCKED')
238
+ }
239
+
240
+ private async loadRow(em: EntityManager, quoteConfigurationId: string, id: string, scope: TenantScope, deleted = false) {
241
+ const row = await findOneWithDecryption(em, CpqQuotePriceAdjustment, { id, quoteConfigurationId, ...scope, ...(deleted ? {} : { deletedAt: null, isActive: true }) }, undefined, scope)
242
+ if (!row) throw new QuotingError(404, 'Adjustment not found')
243
+ return row
244
+ }
245
+ }
246
+
247
+ function snapshotKey(snapshot: AdjustmentSnapshot): string {
248
+ return JSON.stringify(Object.entries(snapshot).sort(([left], [right]) => left.localeCompare(right)))
249
+ }
@@ -0,0 +1,28 @@
1
+ import type { ResolvedCharge } from '../types'
2
+
3
+ /** Normalize legacy snapshots without changing their offer, rules or tier breakdown. */
4
+ export function withChargeWaterfall(charge: ResolvedCharge): ResolvedCharge {
5
+ const adjustments = charge.adjustments ?? []
6
+ const rules = adjustments.filter((entry) => entry.origin !== 'manual')
7
+ const manual = adjustments.find((entry) => entry.origin === 'manual')
8
+ const listUnitPrice = charge.listUnitPrice ?? rules[0]?.unitPriceBefore ?? manual?.unitPriceBefore ?? charge.unitPrice
9
+ const standardUnitPrice = charge.standardUnitPrice ?? rules.at(-1)?.unitPriceAfter ?? manual?.unitPriceBefore ?? charge.unitPrice
10
+ // Keep the exact stored total when no recalculation is needed (tier averages
11
+ // may not multiply back to the byte-identical stored total).
12
+ const totalAt = (unit: number) => charge.totalPrice === null ? null
13
+ : unit === charge.unitPrice ? charge.totalPrice : unit * (charge.quantity ?? 1)
14
+ return {
15
+ ...charge,
16
+ listUnitPrice,
17
+ listTotalPrice: charge.listTotalPrice !== undefined ? charge.listTotalPrice : totalAt(listUnitPrice),
18
+ standardUnitPrice,
19
+ standardTotalPrice: charge.standardTotalPrice !== undefined ? charge.standardTotalPrice : totalAt(standardUnitPrice),
20
+ ...(charge.adjustments ? { adjustments: adjustments.map((entry) => ({ ...entry, origin: entry.origin ?? 'rule' })) } : {}),
21
+ }
22
+ }
23
+
24
+ /** Keep the public calculator's default response compatible and free of waterfall data. */
25
+ export function withoutChargeWaterfall(charge: ResolvedCharge): ResolvedCharge {
26
+ const { listUnitPrice, listTotalPrice, standardUnitPrice, standardTotalPrice, ...publicCharge } = charge
27
+ return publicCharge
28
+ }
@@ -0,0 +1,25 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { CpqQuotePriceAdjustment } from '../../data/entities'
4
+ import { resolvedPriceAdjustmentSchema, type PriceAdjustment } from '../../data/price-adjustments'
5
+ import type { TenantScope } from '../types'
6
+
7
+ /** One aggregate load per recalculation; keyset batches avoid an unbounded query. */
8
+ export async function loadQuoteAdjustments(em: EntityManager, quoteConfigurationId: string, scope: TenantScope): Promise<PriceAdjustment[]> {
9
+ const result: PriceAdjustment[] = []
10
+ let after: string | undefined
11
+ for (;;) {
12
+ const rows = await findWithDecryption(em, CpqQuotePriceAdjustment, {
13
+ quoteConfigurationId, ...scope, deletedAt: null, isActive: true,
14
+ ...(after ? { id: { $gt: after } } : {}),
15
+ }, { limit: 500, orderBy: { id: 'asc' } }, scope)
16
+ for (const row of rows) result.push(resolvedPriceAdjustmentSchema.parse({
17
+ id: row.id, level: row.level, quoteLineId: row.quoteLineId, chargeCode: row.chargeCode,
18
+ chargeTypeFilter: row.chargeTypeFilter, adjustmentType: row.adjustmentType, value: Number(row.value),
19
+ currencyCode: row.currencyCode, reasonCode: row.reasonCode, reasonNote: row.reasonNote,
20
+ fromMonth: row.fromMonth, toMonth: row.toMonth, windowKind: row.windowKind, createdByUserId: row.createdByUserId,
21
+ }))
22
+ if (rows.length < 500) return result
23
+ after = rows[rows.length - 1].id
24
+ }
25
+ }
@@ -0,0 +1,86 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { PriceAdjustment } from '../../data/price-adjustments'
3
+ import type { PricingWaterfallSummary, ResolvedCharge } from '../types'
4
+ import { withChargeWaterfall } from './chargeWaterfall'
5
+
6
+ export interface SnapshotLine {
7
+ lineId: string
8
+ quantity: number
9
+ termMonths?: number | null
10
+ /** ARC mirrors are excluded from deal value until their negotiated re-rate is supported. */
11
+ includeInDeal?: boolean
12
+ charges: ResolvedCharge[]
13
+ }
14
+
15
+ function canonical(value: unknown): string {
16
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
17
+ if (value !== null && typeof value === 'object') {
18
+ const object = value as Record<string, unknown>
19
+ return `{${Object.keys(object).sort().filter((key) => object[key] !== undefined).map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`).join(',')}}`
20
+ }
21
+ return JSON.stringify(value ?? null)
22
+ }
23
+
24
+ /** Signed fractions: a rule surcharge yields a negative discount depth. */
25
+ function depth(before: number, after: number): number | null {
26
+ return before === 0 ? null : 1 - after / before
27
+ }
28
+
29
+ export function computeWaterfallSummary(
30
+ lines: SnapshotLine[],
31
+ adjustments: PriceAdjustment[],
32
+ currencyCode: string,
33
+ ): PricingWaterfallSummary {
34
+ let listNrcTotal = 0, listMrcTotal = 0, standardNrcTotal = 0, standardMrcTotal = 0
35
+ let ruleDiscountTotal = 0, manualDiscountTotal = 0
36
+ const dealValue = { list: 0, standard: 0, offer: 0 }
37
+ const lineDepths: number[] = []
38
+ for (const line of lines) {
39
+ let standardDeal = 0, offerDeal = 0
40
+ for (const input of line.charges) {
41
+ const charge = withChargeWaterfall(input)
42
+ if (charge.chargeType === 'usage') continue
43
+ const list = (charge.listTotalPrice ?? 0) * line.quantity
44
+ const standard = (charge.standardTotalPrice ?? 0) * line.quantity
45
+ const offer = (charge.totalPrice ?? 0) * line.quantity
46
+ if (charge.chargeType === 'nrc') { listNrcTotal += list; standardNrcTotal += standard }
47
+ else { listMrcTotal += list; standardMrcTotal += standard }
48
+ for (const entry of charge.adjustments ?? []) {
49
+ const discount = Math.max(0, -entry.delta) * (charge.quantity ?? 1) * line.quantity
50
+ if (entry.origin === 'manual') manualDiscountTotal += discount
51
+ else ruleDiscountTotal += discount
52
+ }
53
+ if (line.includeInDeal === false) continue
54
+ const months = charge.chargeType === 'nrc' ? 1 : line.termMonths ?? 12
55
+ dealValue.list += list * months
56
+ dealValue.standard += standard * months
57
+ dealValue.offer += offer * months
58
+ standardDeal += standard * months; offerDeal += offer * months
59
+ }
60
+ const manualDepth = depth(standardDeal, offerDeal)
61
+ if (manualDepth !== null) lineDepths.push(manualDepth)
62
+ }
63
+ const pricedLines = lines.map((line) => ({
64
+ lineId: line.lineId, quantity: line.quantity, termMonths: line.termMonths ?? 12, includeInDeal: line.includeInDeal !== false,
65
+ charges: line.charges.map((input) => {
66
+ const charge = withChargeWaterfall(input)
67
+ return { chargeCode: charge.chargeCode, chargeType: charge.chargeType, currencyCode: charge.currencyCode,
68
+ listUnitPrice: charge.listUnitPrice, listTotalPrice: charge.listTotalPrice,
69
+ standardUnitPrice: charge.standardUnitPrice, standardTotalPrice: charge.standardTotalPrice,
70
+ unitPrice: charge.unitPrice, totalPrice: charge.totalPrice }
71
+ }).sort((a, b) => a.chargeCode.localeCompare(b.chargeCode) || canonical(a).localeCompare(canonical(b))),
72
+ })).sort((a, b) => a.lineId.localeCompare(b.lineId))
73
+ const pricedAdjustments = adjustments.map((row) => ({
74
+ id: row.id, level: row.level, quoteLineId: row.quoteLineId, chargeCode: row.chargeCode ?? null,
75
+ chargeTypeFilter: row.chargeTypeFilter ?? null, adjustmentType: row.adjustmentType, value: row.value,
76
+ currencyCode: row.currencyCode ?? null, windowKind: row.windowKind ?? 'relative', fromMonth: row.fromMonth ?? 1, toMonth: row.toMonth ?? null,
77
+ })).sort((a, b) => a.id.localeCompare(b.id))
78
+ return {
79
+ listNrcTotal, listMrcTotal, standardNrcTotal, standardMrcTotal,
80
+ ruleDiscountTotal, manualDiscountTotal, dealValue,
81
+ discountDepth: { rule: depth(dealValue.list, dealValue.standard), manual: depth(dealValue.standard, dealValue.offer),
82
+ total: depth(dealValue.list, dealValue.offer), maxLineManual: lineDepths.length ? Math.max(...lineDepths) : null },
83
+ hasTimeBasedAdjustments: false,
84
+ snapshotHash: createHash('sha256').update(canonical({ currencyCode, lines: pricedLines, adjustments: pricedAdjustments })).digest('hex'),
85
+ }
86
+ }
@@ -0,0 +1,68 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
5
+ import { SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
6
+ import { CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
7
+ import type { TenantScope } from '../types'
8
+
9
+ export async function cloneQuoteAdjustments(em: EntityManager, sourceId: string, target: { id: string; quoteId: string }, lineIds: Map<string, string>, scope: TenantScope): Promise<void> {
10
+ let after: string | undefined
11
+ for (;;) {
12
+ const rows = await findWithDecryption(em, CpqQuotePriceAdjustment, { quoteConfigurationId: sourceId, ...scope, deletedAt: null,
13
+ ...(after ? { id: { $gt: after } } : {}) }, { limit: 500, orderBy: { id: 'asc' } }, scope)
14
+ for (const row of rows) {
15
+ em.persist(em.create(CpqQuotePriceAdjustment, { id: randomUUID(), ...scope, quoteConfigurationId: target.id, quoteId: target.quoteId,
16
+ // An orphaned invalid target remains invalid, without pointing back into the source quote.
17
+ quoteLineId: lineIds.get(row.quoteLineId) ?? randomUUID(), level: row.level, chargeCode: row.chargeCode,
18
+ chargeTypeFilter: row.chargeTypeFilter, adjustmentType: row.adjustmentType, value: row.value, currencyCode: row.currencyCode,
19
+ windowKind: row.windowKind, fromMonth: row.fromMonth, toMonth: row.toMonth,
20
+ reasonCode: row.reasonCode, reasonNote: row.reasonNote, createdByUserId: row.createdByUserId, isActive: row.isActive }))
21
+ }
22
+ if (rows.length < 500) return
23
+ after = rows[rows.length - 1].id
24
+ }
25
+ }
26
+
27
+ /** Delete the complete bundle subtree and its adjustments before recalculation reads it. */
28
+ export async function removeQuoteLineTree(em: EntityManager, quoteConfigurationId: string, root: CpqQuoteLineConfiguration, scope: TenantScope): Promise<void> {
29
+ const children = new Map<string, CpqQuoteLineConfiguration[]>()
30
+ let after: string | undefined
31
+ for (;;) {
32
+ const rows = await findWithDecryption(em, CpqQuoteLineConfiguration, { quoteConfigurationId, ...scope, deletedAt: null,
33
+ ...(after ? { id: { $gt: after } } : {}) }, { limit: 500, orderBy: { id: 'asc' } }, scope)
34
+ for (const row of rows) if (row.parentLineId) {
35
+ const siblings = children.get(row.parentLineId) ?? []
36
+ siblings.push(row); children.set(row.parentLineId, siblings)
37
+ }
38
+ if (rows.length < 500) break
39
+ after = rows[rows.length - 1].id
40
+ }
41
+ const selected: CpqQuoteLineConfiguration[] = [], seen = new Set<string>()
42
+ const queue = [root]
43
+ for (let index = 0; index < queue.length; index++) {
44
+ const line = queue[index]
45
+ if (seen.has(line.quoteLineId)) continue
46
+ seen.add(line.quoteLineId); selected.push(line); queue.push(...(children.get(line.quoteLineId) ?? []))
47
+ }
48
+ const sales: SalesQuoteLine[] = [], adjustments: CpqQuotePriceAdjustment[] = []
49
+ const ids = selected.map((line) => line.quoteLineId)
50
+ for (let index = 0; index < ids.length; index += 500) {
51
+ const lineIds = ids.slice(index, index + 500)
52
+ sales.push(...await findWithDecryption(em, SalesQuoteLine, { id: { $in: lineIds }, ...scope, deletedAt: null }, { limit: 500 }, scope))
53
+ let cursor: string | undefined
54
+ for (;;) {
55
+ const rows = await findWithDecryption(em, CpqQuotePriceAdjustment, { quoteConfigurationId, quoteLineId: { $in: lineIds }, ...scope, deletedAt: null,
56
+ ...(cursor ? { id: { $gt: cursor } } : {}) }, { limit: 500, orderBy: { id: 'asc' } }, scope)
57
+ adjustments.push(...rows)
58
+ if (rows.length < 500) break
59
+ cursor = rows[rows.length - 1].id
60
+ }
61
+ }
62
+ const now = new Date()
63
+ await withAtomicFlush(em, [() => {
64
+ for (const line of selected) line.deletedAt = now
65
+ for (const line of sales) line.deletedAt = now
66
+ for (const adjustment of adjustments) { adjustment.deletedAt = now; adjustment.isActive = false }
67
+ }])
68
+ }
@@ -0,0 +1,94 @@
1
+ import { emitCpqEvent } from '../../events'
2
+ import type { CpqQuoteConfiguration } from '../../data/entities'
3
+ import type { PricingWaterfallSummary } from '../types'
4
+
5
+ export type QuotePricingCause = 'adjustment' | 'line' | 'recalculate' | 'clone'
6
+ export type QuoteStatusCause = 'manual' | 'auto'
7
+
8
+ /** Describes the write so its committed effect can be named in the events. */
9
+ export interface QuoteWriteEvents {
10
+ pricingCause: QuotePricingCause
11
+ /** `manual` only for an operator-requested transition; recalculation-driven changes are `auto`. */
12
+ statusCause?: QuoteStatusCause
13
+ actorUserId?: string | null
14
+ }
15
+
16
+ export interface QuoteEventState {
17
+ snapshotHash: string | null
18
+ cpqStatus: string
19
+ }
20
+
21
+ type SnapshotSource = Pick<CpqQuoteConfiguration, 'id' | 'cpqStatus' | 'currencyCode' | 'pricingRevision' | 'pricingSummary' | 'tenantId' | 'organizationId'>
22
+ & { quoteType?: string | null }
23
+
24
+ export interface QuotePricingSnapshot {
25
+ quoteId: string
26
+ quoteType: 'new' | 'amend' | 'renew' | 'cancel'
27
+ cpqStatus: string
28
+ currencyCode: string
29
+ pricingRevision: number
30
+ snapshotHash: string
31
+ dealValue: PricingWaterfallSummary['dealValue']
32
+ discountDepth: PricingWaterfallSummary['discountDepth']
33
+ hasTimeBasedAdjustments: boolean
34
+ }
35
+
36
+ type Emit = (eventId: string, payload: Record<string, unknown>) => Promise<unknown> | unknown
37
+
38
+ function storedHash(summary: Record<string, unknown> | null | undefined): string | null {
39
+ const hash = summary?.snapshotHash
40
+ return typeof hash === 'string' && hash.length > 0 ? hash : null
41
+ }
42
+
43
+ export function captureQuoteEventState(quote: Pick<CpqQuoteConfiguration, 'cpqStatus' | 'pricingSummary'>): QuoteEventState {
44
+ return { snapshotHash: storedHash(quote.pricingSummary), cpqStatus: quote.cpqStatus }
45
+ }
46
+
47
+ export function buildQuotePricingSnapshot(quote: SnapshotSource): QuotePricingSnapshot {
48
+ const summary = (quote.pricingSummary ?? {}) as Partial<PricingWaterfallSummary>
49
+ const quoteType = quote.quoteType === 'amend' || quote.quoteType === 'renew' || quote.quoteType === 'cancel' ? quote.quoteType : 'new'
50
+ return {
51
+ quoteId: quote.id,
52
+ quoteType,
53
+ cpqStatus: quote.cpqStatus,
54
+ currencyCode: quote.currencyCode,
55
+ pricingRevision: quote.pricingRevision ?? 0,
56
+ snapshotHash: storedHash(quote.pricingSummary) ?? '',
57
+ dealValue: summary.dealValue ?? { list: 0, standard: 0, offer: 0 },
58
+ discountDepth: summary.discountDepth ?? { rule: null, manual: null, total: null, maxLineManual: null },
59
+ hasTimeBasedAdjustments: summary.hasTimeBasedAdjustments === true,
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Emits the committed effect of one quote write. Call only after the
65
+ * transaction has committed. A null previous hash is a legacy quote's first
66
+ * baseline and never counts as a pricing change. Dispatch failures are logged
67
+ * and swallowed: the write stands and the stored hash stays authoritative.
68
+ */
69
+ export async function emitQuoteWriteEvents(
70
+ before: QuoteEventState | null,
71
+ quote: SnapshotSource,
72
+ options: QuoteWriteEvents,
73
+ emit: Emit = emitCpqEvent as unknown as Emit,
74
+ ): Promise<void> {
75
+ const after = captureQuoteEventState(quote)
76
+ const base = { tenantId: quote.tenantId, organizationId: quote.organizationId, ...buildQuotePricingSnapshot(quote), occurredAt: new Date().toISOString() }
77
+ const pricingChanged = after.snapshotHash !== null
78
+ && (before === null ? true : before.snapshotHash !== null && before.snapshotHash !== after.snapshotHash)
79
+ const pending: Array<[string, Record<string, unknown>]> = []
80
+ if (pricingChanged) pending.push(['cpq.quote.pricing_changed', { ...base, cause: options.pricingCause }])
81
+ if (before !== null && before.cpqStatus !== after.cpqStatus) {
82
+ pending.push(['cpq.quote.status_changed', {
83
+ ...base, fromStatus: before.cpqStatus, toStatus: after.cpqStatus,
84
+ cause: options.statusCause ?? 'auto', actorUserId: options.actorUserId ?? null,
85
+ }])
86
+ }
87
+ for (const [eventId, payload] of pending) {
88
+ try {
89
+ await emit(eventId, payload)
90
+ } catch (error) {
91
+ console.error(`[cpq] failed to emit ${eventId} for quote ${quote.id}`, error)
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,51 @@
1
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
2
+ import { LockMode } from '@mikro-orm/core'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import type { EntityManager } from '@mikro-orm/postgresql'
5
+ import { CpqQuoteConfiguration } from '../../data/entities'
6
+ import type { TenantScope } from '../types'
7
+ import { captureQuoteEventState, emitQuoteWriteEvents, type QuoteEventState, type QuoteWriteEvents } from './quotePricingEvents'
8
+
9
+ /** Every pricing write and conversion locks the same scoped aggregate row. */
10
+ export async function withQuotePricingWrite<T>(
11
+ em: EntityManager,
12
+ quoteId: string,
13
+ scope: TenantScope,
14
+ execute: (transaction: EntityManager, quote: CpqQuoteConfiguration) => Promise<T>,
15
+ makeError: (status: number, message: string) => Error,
16
+ expectedRevision?: number,
17
+ validate?: (quote: CpqQuoteConfiguration) => void,
18
+ events: QuoteWriteEvents = { pricingCause: 'recalculate' },
19
+ ): Promise<T> {
20
+ // Do not inherit managed objects/pending writes or propagate committed state
21
+ // back into the caller's identity map. The returned DTO is authoritative;
22
+ // callers that need fresh entities must reload them in their own context.
23
+ // The isolated fork opens its own connection. Called from inside another
24
+ // transaction it could not see that transaction's uncommitted quote, and it
25
+ // would block forever on a row lock that transaction holds — fail fast.
26
+ if (em.isInTransaction?.()) throw new Error('withQuotePricingWrite must not run inside an existing transaction')
27
+ const isolated = em.fork({ clear: true, useContext: false })
28
+ const committed: { quote?: CpqQuoteConfiguration; before?: QuoteEventState } = {}
29
+ const result = await isolated.transactional(async (transaction) => {
30
+ const scoped = { organizationId: scope.organizationId, tenantId: scope.tenantId, deletedAt: null }
31
+ const options = { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true }
32
+ const quote = await findOneWithDecryption(transaction, CpqQuoteConfiguration, { ...scoped, id: quoteId }, options, scope)
33
+ ?? await findOneWithDecryption(transaction, CpqQuoteConfiguration, { ...scoped, quoteId }, options, scope)
34
+ if (!quote) throw makeError(404, 'Quote not found')
35
+ const revision = quote.pricingRevision ?? 0
36
+ if (expectedRevision !== undefined && expectedRevision !== revision) throw makeError(409, 'STALE_REVISION')
37
+ validate?.(quote)
38
+ committed.quote = quote
39
+ committed.before = captureQuoteEventState(quote)
40
+ // This scalar update and every later flush are inside the same DB transaction.
41
+ let written!: T
42
+ await withAtomicFlush(transaction, [
43
+ () => { quote.pricingRevision = revision + 1 },
44
+ async () => { written = await execute(transaction, quote) },
45
+ ])
46
+ return written
47
+ }, { clear: true })
48
+ // `transactional` resolved, so the write is committed; a rolled-back write never reaches here.
49
+ if (committed.quote && committed.before) await emitQuoteWriteEvents(committed.before, committed.quote, events)
50
+ return result
51
+ }