@dainamite/cpq 0.4.0 → 0.6.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 (273) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/MIGRATION.md +101 -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/handleArcError.js +2 -2
  8. package/dist/modules/cpq/api/_helpers/handleArcError.js.map +2 -2
  9. package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js +23 -0
  10. package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js.map +7 -0
  11. package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js +19 -0
  12. package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js.map +7 -0
  13. package/dist/modules/cpq/api/orders/[orderId]/activate/route.js +2 -2
  14. package/dist/modules/cpq/api/orders/[orderId]/activate/route.js.map +2 -2
  15. package/dist/modules/cpq/api/orders/[orderId]/route.js +19 -3
  16. package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
  17. package/dist/modules/cpq/api/orders/[orderId]/status/route.js +2 -2
  18. package/dist/modules/cpq/api/orders/[orderId]/status/route.js.map +2 -2
  19. package/dist/modules/cpq/api/orders/route.js +49 -4
  20. package/dist/modules/cpq/api/orders/route.js.map +2 -2
  21. package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js +2 -2
  22. package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js.map +2 -2
  23. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js +54 -0
  24. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js.map +7 -0
  25. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +45 -5
  26. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
  27. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -2
  28. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +2 -2
  29. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js +47 -0
  30. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js.map +7 -0
  31. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js +30 -0
  32. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js.map +7 -0
  33. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js +47 -0
  34. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js.map +7 -0
  35. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +34 -4
  36. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
  37. package/dist/modules/cpq/api/quotes/[quoteId]/route.js +2 -2
  38. package/dist/modules/cpq/api/quotes/[quoteId]/route.js.map +2 -2
  39. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +33 -2
  40. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +2 -2
  41. package/dist/modules/cpq/api/quotes/price/route.js +43 -3
  42. package/dist/modules/cpq/api/quotes/price/route.js.map +2 -2
  43. package/dist/modules/cpq/api/quotes/route.js +2 -2
  44. package/dist/modules/cpq/api/quotes/route.js.map +2 -2
  45. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +1 -1
  46. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +1 -1
  47. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +1 -1
  48. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +1 -1
  49. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +22 -8
  50. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
  51. package/dist/modules/cpq/backend/cpq/offerings/page.js.map +1 -1
  52. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +89 -232
  53. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
  54. package/dist/modules/cpq/backend/cpq/orders/page.js.map +1 -1
  55. package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
  56. package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +1 -1
  57. package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +1 -1
  58. package/dist/modules/cpq/backend/cpq/pricing/page.js.map +1 -1
  59. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +205 -0
  60. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
  61. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
  62. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
  63. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
  64. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
  65. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
  66. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
  67. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
  68. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
  69. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +144 -221
  70. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
  71. package/dist/modules/cpq/backend/cpq/quotes/page.js.map +1 -1
  72. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
  73. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +1 -1
  74. package/dist/modules/cpq/backend/cpq/specifications/page.js.map +1 -1
  75. package/dist/modules/cpq/backend/cpq/wizards/page.js.map +1 -1
  76. package/dist/modules/cpq/ce.js +8 -0
  77. package/dist/modules/cpq/ce.js.map +2 -2
  78. package/dist/modules/cpq/cli.js +34 -1
  79. package/dist/modules/cpq/cli.js.map +2 -2
  80. package/dist/modules/cpq/commands/price-adjustments.js +107 -0
  81. package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
  82. package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
  83. package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
  84. package/dist/modules/cpq/components/ChargeBreakdownTable.js +154 -0
  85. package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
  86. package/dist/modules/cpq/components/CpqListView.js.map +2 -2
  87. package/dist/modules/cpq/components/PriceWaterfall.js +66 -0
  88. package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
  89. package/dist/modules/cpq/components/PricingSummaryCard.js +117 -0
  90. package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
  91. package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
  92. package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
  93. package/dist/modules/cpq/data/entities.js +97 -0
  94. package/dist/modules/cpq/data/entities.js.map +2 -2
  95. package/dist/modules/cpq/data/price-adjustments.js +70 -0
  96. package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
  97. package/dist/modules/cpq/data/validators.js +4 -0
  98. package/dist/modules/cpq/data/validators.js.map +2 -2
  99. package/dist/modules/cpq/di.js +6 -0
  100. package/dist/modules/cpq/di.js.map +2 -2
  101. package/dist/modules/cpq/events.js +47 -0
  102. package/dist/modules/cpq/events.js.map +2 -2
  103. package/dist/modules/cpq/index.js +1 -1
  104. package/dist/modules/cpq/index.js.map +2 -2
  105. package/dist/modules/cpq/lib/auditActor.js +10 -0
  106. package/dist/modules/cpq/lib/auditActor.js.map +7 -0
  107. package/dist/modules/cpq/lib/formatMoney.js +7 -0
  108. package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
  109. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
  110. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
  111. package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
  112. package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
  113. package/dist/modules/cpq/lib/quotePermissions.js +13 -0
  114. package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
  115. package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
  116. package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
  117. package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
  118. package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
  119. package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
  120. package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
  121. package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
  122. package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
  123. package/dist/modules/cpq/services/cpqOrderService.js +34 -30
  124. package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
  125. package/dist/modules/cpq/services/cpqPricingService.js +7 -0
  126. package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
  127. package/dist/modules/cpq/services/cpqQuotingService.js +339 -111
  128. package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
  129. package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
  130. package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
  131. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
  132. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
  133. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
  134. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
  135. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
  136. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
  137. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
  138. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
  139. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
  140. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
  141. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
  142. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
  143. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
  144. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
  145. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
  146. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
  147. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
  148. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
  149. package/dist/modules/cpq/services/types.js.map +2 -2
  150. package/dist/modules/cpq/setup.js +6 -0
  151. package/dist/modules/cpq/setup.js.map +2 -2
  152. package/package.json +9 -9
  153. package/src/modules/cpq/__integration__/TC-CPQ-002.spec.ts +8 -5
  154. package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
  155. package/src/modules/cpq/acl.ts +1 -0
  156. package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
  157. package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
  158. package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
  159. package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
  160. package/src/modules/cpq/api/_helpers/handleArcError.ts +12 -12
  161. package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
  162. package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
  163. package/src/modules/cpq/api/orders/[orderId]/activate/route.ts +2 -2
  164. package/src/modules/cpq/api/orders/[orderId]/route.ts +20 -2
  165. package/src/modules/cpq/api/orders/[orderId]/status/route.ts +2 -2
  166. package/src/modules/cpq/api/orders/route.ts +53 -3
  167. package/src/modules/cpq/api/quotes/[quoteId]/clone/route.ts +2 -2
  168. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
  169. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +51 -4
  170. package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
  171. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
  172. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
  173. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
  174. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
  175. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +42 -3
  176. package/src/modules/cpq/api/quotes/[quoteId]/route.ts +2 -2
  177. package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
  178. package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
  179. package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
  180. package/src/modules/cpq/api/quotes/price/route.ts +28 -2
  181. package/src/modules/cpq/api/quotes/route.ts +2 -2
  182. package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +1 -1
  183. package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +1 -1
  184. package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +1 -1
  185. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +32 -10
  186. package/src/modules/cpq/backend/cpq/offerings/page.tsx +1 -1
  187. package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +60 -269
  188. package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
  189. package/src/modules/cpq/backend/cpq/orders/page.tsx +1 -1
  190. package/src/modules/cpq/backend/cpq/price-rules/page.tsx +1 -1
  191. package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
  192. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +260 -0
  193. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
  194. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
  195. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
  196. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
  197. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
  198. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +178 -266
  199. package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
  200. package/src/modules/cpq/backend/cpq/quotes/page.tsx +1 -1
  201. package/src/modules/cpq/backend/cpq/specifications/page.tsx +1 -1
  202. package/src/modules/cpq/backend/cpq/wizards/page.tsx +1 -1
  203. package/src/modules/cpq/ce.ts +8 -0
  204. package/src/modules/cpq/cli.ts +48 -1
  205. package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
  206. package/src/modules/cpq/commands/price-adjustments.ts +90 -0
  207. package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
  208. package/src/modules/cpq/components/ChargeBreakdownTable.tsx +201 -0
  209. package/src/modules/cpq/components/CpqListView.tsx +2 -1
  210. package/src/modules/cpq/components/PriceWaterfall.tsx +94 -0
  211. package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
  212. package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
  213. package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
  214. package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
  215. package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
  216. package/src/modules/cpq/data/entities.ts +83 -0
  217. package/src/modules/cpq/data/price-adjustments.ts +67 -0
  218. package/src/modules/cpq/data/validators.ts +3 -0
  219. package/src/modules/cpq/di.ts +6 -0
  220. package/src/modules/cpq/events.ts +53 -2
  221. package/src/modules/cpq/index.ts +1 -1
  222. package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
  223. package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
  224. package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
  225. package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
  226. package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
  227. package/src/modules/cpq/lib/auditActor.ts +13 -0
  228. package/src/modules/cpq/lib/formatMoney.ts +4 -0
  229. package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
  230. package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
  231. package/src/modules/cpq/lib/quotePermissions.ts +22 -0
  232. package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
  233. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
  234. package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
  235. package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
  236. package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
  237. package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
  238. package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
  239. package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
  240. package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
  241. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
  242. package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
  243. package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
  244. package/src/modules/cpq/services/__tests__/errorBrands.test.ts +72 -0
  245. package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
  246. package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
  247. package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
  248. package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
  249. package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
  250. package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
  251. package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
  252. package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
  253. package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
  254. package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
  255. package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
  256. package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
  257. package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
  258. package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
  259. package/src/modules/cpq/services/cpqOrderService.ts +32 -28
  260. package/src/modules/cpq/services/cpqPricingService.ts +8 -0
  261. package/src/modules/cpq/services/cpqQuotingService.ts +388 -125
  262. package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
  263. package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
  264. package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
  265. package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
  266. package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
  267. package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
  268. package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
  269. package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
  270. package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
  271. package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
  272. package/src/modules/cpq/services/types.ts +34 -1
  273. package/src/modules/cpq/setup.ts +7 -1
@@ -0,0 +1,37 @@
1
+ import { seedPriceAdjustmentDefaults } from '../priceAdjustmentDefaults'
2
+ import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
3
+ import { FeatureToggle } from '@open-mercato/core/modules/feature_toggles/data/entities'
4
+ import { DEFAULT_ADJUSTMENT_REASONS } from '../../data/price-adjustments'
5
+ import { setup } from '../../setup'
6
+ import { features } from '../../acl'
7
+ const scope = { tenantId: 'tenant', organizationId: 'org' }
8
+ it('seeds seven editable reasons idempotently without resetting a disabled rollout toggle or custom label', async () => {
9
+ let dictionary: Record<string, unknown> | null = null
10
+ const entries: Array<Record<string, unknown>> = []
11
+ const flag = { identifier: 'cpq.manual_price_adjustments', defaultValue: false }
12
+ const em = {
13
+ upsert: jest.fn(async () => flag),
14
+ findOne: jest.fn(async () => dictionary), find: jest.fn(async () => entries),
15
+ create: jest.fn((entity: unknown, values: Record<string, unknown>) => {
16
+ const row = { id: `id-${entries.length}`, ...values }
17
+ if (entity === Dictionary) dictionary = row
18
+ if (entity === DictionaryEntry) entries.push(row)
19
+ return row
20
+ }), persist: jest.fn(), flush: jest.fn().mockResolvedValue(undefined),
21
+ }
22
+ await seedPriceAdjustmentDefaults(em as never, scope)
23
+ expect(entries).toHaveLength(7)
24
+ expect(entries.map((entry) => entry.value)).toEqual(DEFAULT_ADJUSTMENT_REASONS.map((entry) => entry.value))
25
+ expect(dictionary).toMatchObject({ managerVisibility: 'default', isSystem: false, ...scope })
26
+ entries[0].label = 'Tenant custom label'
27
+ await seedPriceAdjustmentDefaults(em as never, scope)
28
+ expect(entries).toHaveLength(7)
29
+ expect(entries[0].label).toBe('Tenant custom label')
30
+ expect(flag.defaultValue).toBe(false)
31
+ expect(em.upsert).toHaveBeenCalledWith(FeatureToggle, expect.objectContaining({ defaultValue: true, createdAt: expect.any(Date), updatedAt: expect.any(Date) }), { onConflictFields: ['identifier'], onConflictAction: 'ignore' })
32
+ })
33
+ it('declares and grants adjustment permission to both sales roles', () => {
34
+ expect(features.some((entry) => entry.id === 'cpq.quotes.adjust_price')).toBe(true)
35
+ expect(setup.defaultRoleFeatures?.admin).toContain('cpq.quotes.adjust_price')
36
+ expect(setup.defaultRoleFeatures?.employee).toContain('cpq.quotes.adjust_price')
37
+ })
@@ -0,0 +1,152 @@
1
+ import {
2
+ adjustmentAccess, adjustmentErrorCode, collectAdjustmentErrors, allowedAdjustmentTypes, buildAdjustmentDraft, canDeleteInvalidAdjustment, discountDepth, formatDepth, parseStoredCharge, toAdjustmentPatch,
3
+ lineAdjustmentBlock, lineWaterfallTotals, waterfallRows,
4
+ } from '../priceAdjustmentView'
5
+ import { TERMINAL_STATUSES } from '../../services/types'
6
+
7
+ const rule = { origin: 'rule' as const, ruleCode: 'VOL', ruleName: 'Volume', ruleType: 'discount_percent', value: 10, unitPriceBefore: 100, unitPriceAfter: 90, delta: -10 }
8
+ const manual = { origin: 'manual' as const, adjustmentId: 'a1', ruleCode: 'a1', ruleName: 'competitive_match', ruleType: 'price_override', value: 80, unitPriceBefore: 90, unitPriceAfter: 80, delta: -10, reasonCode: 'competitive_match' }
9
+
10
+ describe('waterfallRows', () => {
11
+ it('orders list, rules, standard, manual, offer', () => {
12
+ const rows = waterfallRows({ chargeType: 'mrc', unitPrice: 80, totalPrice: 80, listUnitPrice: 100, standardUnitPrice: 90, adjustments: [manual, rule] })
13
+ expect(rows.map((row) => row.kind)).toEqual(['list', 'rule', 'standard', 'manual', 'offer'])
14
+ expect(rows[0]).toEqual({ kind: 'list', unitPrice: 100 })
15
+ expect(rows[2]).toEqual({ kind: 'standard', unitPrice: 90 })
16
+ expect(rows[3]).toMatchObject({ reasonCode: 'competitive_match', adjustmentId: 'a1', delta: -10 })
17
+ expect(rows[4]).toEqual({ kind: 'offer', unitPrice: 80 })
18
+ })
19
+
20
+ it('shows list and offer only when nothing adjusted the price', () => {
21
+ expect(waterfallRows({ chargeType: 'nrc', unitPrice: 50, totalPrice: 50 }).map((row) => row.kind)).toEqual(['list', 'offer'])
22
+ })
23
+
24
+ it('treats legacy rule entries without origin as rules and derives list from them', () => {
25
+ const { origin: _origin, ...legacy } = rule
26
+ const rows = waterfallRows({ chargeType: 'mrc', unitPrice: 90, totalPrice: 90, adjustments: [legacy] })
27
+ expect(rows).toEqual([
28
+ { kind: 'list', unitPrice: 100 },
29
+ expect.objectContaining({ kind: 'rule', label: 'Volume' }),
30
+ { kind: 'standard', unitPrice: 90 },
31
+ { kind: 'offer', unitPrice: 90 },
32
+ ])
33
+ })
34
+ })
35
+
36
+ describe('depth', () => {
37
+ it('is signed and formatted with one decimal', () => {
38
+ expect(discountDepth(100, 80)).toBeCloseTo(0.2)
39
+ expect(formatDepth(discountDepth(100, 80))).toBe('−20%')
40
+ expect(formatDepth(discountDepth(100, 112.5))).toBe('+12.5%')
41
+ expect(formatDepth(discountDepth(100, 100))).toBeNull()
42
+ expect(discountDepth(0, 0)).toBeNull()
43
+ expect(discountDepth(null, 5)).toBeNull()
44
+ })
45
+
46
+ it('totals a line by quantity and skips usage', () => {
47
+ expect(lineWaterfallTotals({ quantity: 2, charges: [
48
+ { chargeType: 'mrc', unitPrice: 80, totalPrice: 80, listTotalPrice: 100, standardTotalPrice: 90 },
49
+ { chargeType: 'usage', unitPrice: 1, totalPrice: null },
50
+ { chargeType: 'nrc', unitPrice: 10, totalPrice: 10 },
51
+ ] })).toEqual({ list: 220, standard: 200, offer: 180 })
52
+ })
53
+ })
54
+
55
+ describe('allowedAdjustmentTypes', () => {
56
+ it.each([
57
+ ['line', undefined, ['discount_percent']],
58
+ ['charge', { chargeType: 'mrc', pricingMethod: 'flat' }, ['price_override', 'discount_percent', 'discount_absolute']],
59
+ ['charge', { chargeType: 'nrc', pricingMethod: 'per_unit' }, ['price_override', 'discount_percent', 'discount_absolute']],
60
+ ['charge', { chargeType: 'mrc', pricingMethod: 'tiered' }, ['discount_percent', 'discount_absolute']],
61
+ ['charge', { chargeType: 'mrc', pricingMethod: 'volume' }, ['discount_percent', 'discount_absolute']],
62
+ ['charge', { chargeType: 'usage', pricingMethod: 'flat' }, []],
63
+ ] as const)('%s %j → %j', (level, charge, expected) => {
64
+ expect(allowedAdjustmentTypes(level, charge)).toEqual(expected)
65
+ })
66
+ })
67
+
68
+ describe('eligibility and access', () => {
69
+ it('blocks ARC mirror and free-form lines', () => {
70
+ expect(lineAdjustmentBlock({ offeringId: 'o', configuration: { _arcMirroredFromItemId: 'i' } }, 'amend')).toBe('mirror')
71
+ expect(lineAdjustmentBlock({ offeringId: 'o', configuration: { _arcMirroredFromItemId: 'i' } }, 'new')).toBeNull()
72
+ expect(lineAdjustmentBlock({ offeringId: null }, 'new')).toBe('free_form')
73
+ expect(lineAdjustmentBlock({ offeringId: null, productId: 'p1' }, 'new')).toBeNull()
74
+ expect(lineAdjustmentBlock({ offeringId: 'o', configuration: {} }, 'renew')).toBeNull()
75
+ })
76
+
77
+ const base = { enabled: true, canAdjust: true, cpqStatus: 'ready', convertedOrderId: null }
78
+ it.each([
79
+ [{}, null],
80
+ [{ enabled: null }, 'loading'],
81
+ [{ enabled: false }, 'disabled'],
82
+ [{ canAdjust: false }, 'no_permission'],
83
+ [{ convertedOrderId: 'order' }, 'converted'],
84
+ [{ cpqStatus: 'in_approval' }, 'locked'],
85
+ ] as const)('%j → %s', (patch, reason) => {
86
+ expect(adjustmentAccess({ ...base, ...patch }).reason).toBe(reason)
87
+ })
88
+
89
+ it('lets invalid adjustments be deleted while submitted, never once final', () => {
90
+ expect(canDeleteInvalidAdjustment({ ...base, cpqStatus: 'in_approval' })).toBe(true)
91
+ for (const status of TERMINAL_STATUSES) expect(canDeleteInvalidAdjustment({ ...base, cpqStatus: status })).toBe(false)
92
+ expect(canDeleteInvalidAdjustment({ ...base, convertedOrderId: 'x' })).toBe(false)
93
+ })
94
+ })
95
+
96
+ describe('request bodies', () => {
97
+ const form = { adjustmentType: 'price_override' as const, value: '80', reasonCode: 'competitive_match', reasonNote: '', chargeTypeFilter: '' as const }
98
+ it('builds a charge override with currency and revision', () => {
99
+ expect(buildAdjustmentDraft(form, { level: 'charge', quoteLineId: 'l1', chargeCode: 'fee' }, 'USD', 3)).toEqual({
100
+ level: 'charge', quoteLineId: 'l1', chargeCode: 'fee', adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match', expectedRevision: 3,
101
+ })
102
+ })
103
+ it('builds a line percentage without currency, with filter and note; accepts a decimal comma', () => {
104
+ expect(buildAdjustmentDraft({ ...form, adjustmentType: 'discount_percent', value: '12,5', chargeTypeFilter: 'mrc', reasonCode: 'other', reasonNote: ' match ' }, { level: 'line', quoteLineId: 'l1' }, 'USD')).toEqual({
105
+ level: 'line', quoteLineId: 'l1', chargeTypeFilter: 'mrc', adjustmentType: 'discount_percent', value: 12.5, reasonCode: 'other', reasonNote: 'match',
106
+ })
107
+ })
108
+ it('returns null for empty or non-numeric values', () => {
109
+ expect(buildAdjustmentDraft({ ...form, value: ' ' }, { level: 'line', quoteLineId: 'l1' }, 'USD')).toBeNull()
110
+ expect(buildAdjustmentDraft({ ...form, value: 'abc' }, { level: 'line', quoteLineId: 'l1' }, 'USD')).toBeNull()
111
+ })
112
+ it('drops immutable target fields from a patch and clears removed notes', () => {
113
+ const draft = buildAdjustmentDraft(form, { level: 'charge', quoteLineId: 'l1', chargeCode: 'fee' }, 'USD', 2)!
114
+ expect(toAdjustmentPatch(draft)).toEqual({ adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match', reasonNote: null, expectedRevision: 2 })
115
+ })
116
+ it.each([
117
+ [409, { error: 'STALE_REVISION' }, 'STALE_REVISION'],
118
+ [400, { error: 'Validation error', details: [{ message: 'Invalid' }, { message: 'INVALID_PERCENT' }] }, 'INVALID_PERCENT'],
119
+ [422, { error: 'ABOVE_STANDARD' }, 'ABOVE_STANDARD'],
120
+ [403, { error: 'Manual price adjustments are disabled' }, 'FORBIDDEN'],
121
+ [500, null, 'UNKNOWN'],
122
+ ])('maps %s %j → %s', (status, body, code) => {
123
+ expect(adjustmentErrorCode(status, body)).toBe(code)
124
+ })
125
+ })
126
+
127
+ describe('parseStoredCharge', () => {
128
+ it('keeps waterfall fields and well-formed adjustments, drops malformed ones', () => {
129
+ const parsed = parseStoredCharge({ chargeCode: 'fee', chargeType: 'mrc', unitPrice: 80, totalPrice: 80, quantity: 1, listUnitPrice: 100, standardUnitPrice: 90,
130
+ adjustments: [{ ...manual }, { ruleType: 'x' }, null] })
131
+ expect(parsed).toMatchObject({ chargeName: 'fee', chargeType: 'mrc', unitPrice: 80, listUnitPrice: 100, standardUnitPrice: 90, quantity: 1 })
132
+ expect(parsed?.adjustments).toHaveLength(1)
133
+ })
134
+ it.each([null, 'x', { chargeType: 'mrc' }, { chargeType: 'other', unitPrice: 1 }])('rejects %j', (raw) => {
135
+ expect(parseStoredCharge(raw)).toBeNull()
136
+ })
137
+ })
138
+
139
+ describe('collectAdjustmentErrors', () => {
140
+ it('reads recalculation errors, charge errors and persisted validation errors (after reload)', () => {
141
+ const map = collectAdjustmentErrors({
142
+ adjustmentErrors: [{ adjustmentId: 'a1', code: 'ABOVE_STANDARD' }],
143
+ lines: [{ charges: [{ adjustmentErrors: [{ adjustmentId: 'a1', code: 'CURRENCY_MISMATCH' }, { adjustmentId: 'a2', code: 'ADJUSTMENT_OVERLAP' }] }],
144
+ validationErrors: [{ adjustmentId: 'a3', message: 'ADJUSTMENT_TARGET_MISSING' }, { message: 'Port size is required' }] }],
145
+ validationResult: { errors: [{ adjustmentId: 'a4', message: 'TARGET_AMBIGUOUS' }] },
146
+ })
147
+ expect(Object.fromEntries(map)).toEqual({ a1: 'ABOVE_STANDARD', a2: 'ADJUSTMENT_OVERLAP', a3: 'ADJUSTMENT_TARGET_MISSING', a4: 'TARGET_AMBIGUOUS' })
148
+ })
149
+ it('is empty for a missing quote', () => {
150
+ expect(collectAdjustmentErrors(null).size).toBe(0)
151
+ })
152
+ })
@@ -0,0 +1,30 @@
1
+ import { canRecalculateQuote, TERMINAL_QUOTE_STATUSES } from '../quotePermissions'
2
+ import { TERMINAL_STATUSES } from '../../services/types'
3
+
4
+ describe('TERMINAL_QUOTE_STATUSES', () => {
5
+ it('mirrors the service-layer TERMINAL_STATUSES', () => {
6
+ expect([...TERMINAL_QUOTE_STATUSES].sort()).toEqual([...TERMINAL_STATUSES].sort())
7
+ })
8
+ })
9
+
10
+ describe('canRecalculateQuote', () => {
11
+ it('allows a user with cpq.quotes.manage on a non-terminal quote', () => {
12
+ expect(canRecalculateQuote(['cpq.quotes.manage'], 'ready')).toBe(true)
13
+ })
14
+
15
+ it('honours wildcard grants', () => {
16
+ expect(canRecalculateQuote(['cpq.*'], 'incomplete')).toBe(true)
17
+ })
18
+
19
+ it('denies a view-only user', () => {
20
+ expect(canRecalculateQuote(['cpq.quotes.view'], 'ready')).toBe(false)
21
+ })
22
+
23
+ it('denies before the feature check has loaded (no granted features)', () => {
24
+ expect(canRecalculateQuote([], 'ready')).toBe(false)
25
+ })
26
+
27
+ it.each(['accepted', 'rejected', 'cancelled'])('denies on a %s (terminal) quote', (status) => {
28
+ expect(canRecalculateQuote(['cpq.quotes.manage'], status)).toBe(false)
29
+ })
30
+ })
@@ -0,0 +1,56 @@
1
+ import { arcRecurringDelta, chargeTypeDepths, formatChangePct } from '../quoteSummaryView'
2
+
3
+ describe('chargeTypeDepths', () => {
4
+ it('computes list → offer depth per charge type', () => {
5
+ const depths = chargeTypeDepths({ nrcTotal: 80, mrcTotal: 90, listNrcTotal: 100, listMrcTotal: 100 })
6
+ expect(depths.nrc).toBeCloseTo(0.2)
7
+ expect(depths.mrc).toBeCloseTo(0.1)
8
+ })
9
+
10
+ it('returns null for legacy snapshots and zero list', () => {
11
+ expect(chargeTypeDepths({ nrcTotal: 80, mrcTotal: 90 })).toEqual({ nrc: null, mrc: null })
12
+ expect(chargeTypeDepths({ nrcTotal: 0, mrcTotal: 0, listNrcTotal: 0, listMrcTotal: 0 })).toEqual({ nrc: null, mrc: null })
13
+ })
14
+ })
15
+
16
+ describe('arcRecurringDelta', () => {
17
+ it('compares mirrored items against surviving lines', () => {
18
+ const result = arcRecurringDelta([
19
+ { action: 'modify', mrcTotal: 120, arcSource: { mrcAmount: 100 } }, // re-rated
20
+ { action: 'cancel', mrcTotal: 50, arcSource: { mrcAmount: 50 } }, // removed
21
+ { action: 'add', mrcTotal: 30, arcSource: null }, // new
22
+ ])
23
+ expect(result).toEqual({ currentMrc: 150, newMrc: 150, delta: 0, deltaPct: 0 })
24
+ })
25
+
26
+ it('reports a signed change and percentage', () => {
27
+ const result = arcRecurringDelta([
28
+ { action: 'modify', mrcTotal: 100, arcSource: { mrcAmount: 100 } },
29
+ { action: 'add', mrcTotal: 25, arcSource: null },
30
+ ])
31
+ expect(result.delta).toBe(25)
32
+ expect(result.deltaPct).toBeCloseTo(0.25)
33
+ })
34
+
35
+ it('has no percentage when there is no current recurring value', () => {
36
+ expect(arcRecurringDelta([{ action: 'add', mrcTotal: 10, arcSource: null }]).deltaPct).toBeNull()
37
+ })
38
+
39
+ it('replacing an item: the removed item counts as current, only the new one as new', () => {
40
+ // Amend: existing $400 port removed (cancel line, zero totals) and a $1,130 port added.
41
+ const result = arcRecurringDelta([
42
+ { action: 'cancel', mrcTotal: 0, arcSource: { mrcAmount: 400 } },
43
+ { action: 'add', mrcTotal: 1130, arcSource: null },
44
+ ])
45
+ expect(result).toEqual({ currentMrc: 400, newMrc: 1130, delta: 730, deltaPct: 1.825 })
46
+ })
47
+ })
48
+
49
+ describe('formatChangePct', () => {
50
+ it('formats signed changes and hides ~0', () => {
51
+ expect(formatChangePct(0.25)).toBe('+25%')
52
+ expect(formatChangePct(-0.125)).toBe('−12.5%')
53
+ expect(formatChangePct(0)).toBeNull()
54
+ expect(formatChangePct(null)).toBeNull()
55
+ })
56
+ })
@@ -0,0 +1,13 @@
1
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
2
+
3
+ /**
4
+ * The user id to record as author. Sessions carry the user id in `sub`;
5
+ * API keys carry `api_key:<id>` in `sub` and the owning user (if any) in
6
+ * `userId`. Returns null when no user is behind the request, so callers can
7
+ * refuse cleanly instead of writing a non-uuid into a uuid column.
8
+ */
9
+ export function resolveAuditActor(auth: { sub?: string | null; userId?: string | null } | null | undefined): string | null {
10
+ if (auth?.sub && UUID.test(auth.sub)) return auth.sub
11
+ if (auth?.userId && UUID.test(auth.userId)) return auth.userId
12
+ return null
13
+ }
@@ -0,0 +1,4 @@
1
+ /** Currency amount as shown across CPQ detail pages, e.g. `$1,130.00`. */
2
+ export function formatMoney(amount: number, currency: string): string {
3
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
4
+ }
@@ -0,0 +1,36 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
4
+ import { FeatureToggle } from '@open-mercato/core/modules/feature_toggles/data/entities'
5
+ import { ADJUSTMENT_TOGGLE, REASON_DICTIONARY, DEFAULT_ADJUSTMENT_REASONS } from '../data/price-adjustments'
6
+ import type { TenantScope } from '../services/types'
7
+
8
+ /** Add missing defaults; never overwrite an operator's labels or disabled flags. */
9
+ export async function seedPriceAdjustmentDefaults(em: EntityManager, scope: TenantScope): Promise<void> {
10
+ // Multiple tenants may initialize concurrently. Ignore an existing global
11
+ // identifier (including disabled/deleted toggles), without resetting its value.
12
+ // POJO upserts skip entity property initializers, so timestamps must be explicit
13
+ // (`updated_at` is NOT NULL and only has an onUpdate hook).
14
+ const now = new Date()
15
+ await em.upsert(FeatureToggle, { identifier: ADJUSTMENT_TOGGLE, name: 'CPQ manual price adjustments',
16
+ type: 'boolean', defaultValue: true, category: 'CPQ', createdAt: now, updatedAt: now },
17
+ { onConflictFields: ['identifier'], onConflictAction: 'ignore' })
18
+ let dictionary = await findOneWithDecryption(em, Dictionary, { ...scope, key: REASON_DICTIONARY }, undefined, scope)
19
+ if (!dictionary) {
20
+ dictionary = em.create(Dictionary, { ...scope, key: REASON_DICTIONARY, name: 'Price adjustment reasons',
21
+ description: 'Reasons for negotiated prices on quotes.', managerVisibility: 'default', isSystem: false, isActive: true, createdAt: new Date(), updatedAt: new Date() })
22
+ em.persist(dictionary)
23
+ await em.flush()
24
+ }
25
+ if (dictionary.deletedAt) return
26
+ const existing = await findWithDecryption(em, DictionaryEntry, { ...scope, dictionary: dictionary.id,
27
+ normalizedValue: { $in: DEFAULT_ADJUSTMENT_REASONS.map((entry) => entry.value) } }, { limit: DEFAULT_ADJUSTMENT_REASONS.length }, scope)
28
+ const values = new Set(existing.map((entry) => entry.normalizedValue))
29
+ for (const [position, entry] of DEFAULT_ADJUSTMENT_REASONS.entries()) {
30
+ if (!values.has(entry.value)) {
31
+ em.persist(em.create(DictionaryEntry, { ...scope, dictionary, value: entry.value,
32
+ normalizedValue: entry.value, label: entry.label, position }))
33
+ }
34
+ }
35
+ await em.flush()
36
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Pure view helpers for negotiated quote prices. Client-safe: no service or
3
+ * ORM imports. Rules mirror the server resolver
4
+ * (`services/priceAdjustments/resolveManualAdjustments.ts`) so the UI only
5
+ * offers what the API would accept; the API stays the authority.
6
+ */
7
+
8
+ export const ADJUST_PRICE_FEATURE = 'cpq.quotes.adjust_price'
9
+ /** Matches `assertMutable` in the adjustment service. */
10
+ export const ADJUSTMENT_EDITABLE_STATUSES = ['new', 'incomplete', 'ready'] as const
11
+ /** Statuses where even an invalid adjustment can no longer be deleted. */
12
+ const INVALID_DELETE_BLOCKED = ['accepted', 'rejected', 'cancelled']
13
+
14
+ export type AdjustmentType = 'discount_percent' | 'discount_absolute' | 'price_override'
15
+ export type AdjustmentLevel = 'charge' | 'line'
16
+
17
+ export type WaterfallChargeInput = {
18
+ chargeType: 'nrc' | 'mrc' | 'usage'
19
+ pricingMethod?: string | null
20
+ unitPrice: number
21
+ totalPrice: number | null
22
+ listUnitPrice?: number
23
+ listTotalPrice?: number | null
24
+ standardUnitPrice?: number
25
+ standardTotalPrice?: number | null
26
+ adjustments?: Array<{
27
+ origin?: 'rule' | 'manual'
28
+ adjustmentId?: string
29
+ ruleCode: string
30
+ ruleName: string
31
+ ruleType: string
32
+ value: number
33
+ unitPriceBefore: number
34
+ unitPriceAfter: number
35
+ delta: number
36
+ reasonCode?: string
37
+ createdByUserId?: string
38
+ }> | null
39
+ }
40
+
41
+ export type WaterfallRow =
42
+ | { kind: 'list'; unitPrice: number }
43
+ | { kind: 'rule' | 'manual'; label: string; ruleType: string; value: number; delta: number; unitPriceAfter: number; reasonCode?: string; adjustmentId?: string }
44
+ | { kind: 'standard'; unitPrice: number }
45
+ | { kind: 'offer'; unitPrice: number }
46
+
47
+ /**
48
+ * List → rule rows → standard → manual rows → offer. Legacy snapshots without
49
+ * waterfall fields fall back to the stored unit price for list and standard.
50
+ */
51
+ export function waterfallRows(charge: WaterfallChargeInput): WaterfallRow[] {
52
+ const entries = charge.adjustments ?? []
53
+ const rules = entries.filter((entry) => entry.origin !== 'manual')
54
+ const manual = entries.filter((entry) => entry.origin === 'manual')
55
+ const standard = charge.standardUnitPrice ?? (manual.length ? manual[0].unitPriceBefore : charge.unitPrice)
56
+ const list = charge.listUnitPrice ?? (rules.length ? rules[0].unitPriceBefore : standard)
57
+ const step = (kind: 'rule' | 'manual') => (entry: (typeof entries)[number]): WaterfallRow => ({
58
+ kind, label: entry.ruleName || entry.ruleCode, ruleType: entry.ruleType, value: entry.value,
59
+ delta: entry.delta, unitPriceAfter: entry.unitPriceAfter, reasonCode: entry.reasonCode, adjustmentId: entry.adjustmentId,
60
+ })
61
+ const rows: WaterfallRow[] = [{ kind: 'list', unitPrice: list }, ...rules.map(step('rule'))]
62
+ if (rules.length || manual.length) rows.push({ kind: 'standard', unitPrice: standard })
63
+ rows.push(...manual.map(step('manual')), { kind: 'offer', unitPrice: charge.unitPrice })
64
+ return rows
65
+ }
66
+
67
+ /** Signed fraction: positive = discount, negative = surcharge, null when undefined. */
68
+ export function discountDepth(before: number | null | undefined, after: number | null | undefined): number | null {
69
+ if (before == null || after == null || before === 0) return null
70
+ return 1 - after / before
71
+ }
72
+
73
+ export function formatDepth(depth: number | null): string | null {
74
+ if (depth === null || Math.abs(depth) < 0.00005) return null
75
+ const percent = Math.round(depth * 1000) / 10
76
+ return depth > 0 ? `−${percent}%` : `+${Math.abs(percent)}%`
77
+ }
78
+
79
+ /** Line-level list / standard / offer totals (per line quantity), ignoring usage. */
80
+ export function lineWaterfallTotals(line: { quantity: number; charges: WaterfallChargeInput[] }) {
81
+ let list = 0, standard = 0, offer = 0
82
+ for (const charge of line.charges) {
83
+ if (charge.chargeType === 'usage' || charge.totalPrice == null) continue
84
+ const offerTotal = charge.totalPrice
85
+ const standardTotal = charge.standardTotalPrice ?? offerTotal
86
+ list += (charge.listTotalPrice ?? standardTotal) * line.quantity
87
+ standard += standardTotal * line.quantity
88
+ offer += offerTotal * line.quantity
89
+ }
90
+ return { list, standard, offer }
91
+ }
92
+
93
+ /** Adjustment types the server accepts for a target. */
94
+ export function allowedAdjustmentTypes(level: AdjustmentLevel, charge?: Pick<WaterfallChargeInput, 'chargeType' | 'pricingMethod'>): AdjustmentType[] {
95
+ if (level === 'line') return ['discount_percent']
96
+ if (!charge || charge.chargeType === 'usage') return []
97
+ const overridable = charge.pricingMethod == null || charge.pricingMethod === 'flat' || charge.pricingMethod === 'per_unit'
98
+ return overridable ? ['price_override', 'discount_percent', 'discount_absolute'] : ['discount_percent', 'discount_absolute']
99
+ }
100
+
101
+ export type LineEligibilityInput = {
102
+ offeringId: string | null
103
+ /** Product-only lines are eligible too (server: `offeringId || productId`). */
104
+ productId?: string | null
105
+ configuration?: Record<string, unknown> | null
106
+ }
107
+
108
+ /** ARC mirror lines and free-form lines take no manual adjustments yet. */
109
+ export function lineAdjustmentBlock(line: LineEligibilityInput, quoteType: string): 'mirror' | 'free_form' | null {
110
+ const mirrored = quoteType !== 'new' && typeof line.configuration?._arcMirroredFromItemId === 'string'
111
+ if (mirrored) return 'mirror'
112
+ if (!line.offeringId && !line.productId) return 'free_form'
113
+ return null
114
+ }
115
+
116
+ export type AdjustmentAccess =
117
+ | { editable: true; reason: null }
118
+ | { editable: false; reason: 'loading' | 'disabled' | 'no_permission' | 'converted' | 'locked' }
119
+
120
+ export function adjustmentAccess(input: {
121
+ enabled: boolean | null
122
+ canAdjust: boolean
123
+ cpqStatus: string
124
+ convertedOrderId: string | null
125
+ }): AdjustmentAccess {
126
+ if (input.enabled === null) return { editable: false, reason: 'loading' }
127
+ if (!input.enabled) return { editable: false, reason: 'disabled' }
128
+ if (!input.canAdjust) return { editable: false, reason: 'no_permission' }
129
+ if (input.convertedOrderId) return { editable: false, reason: 'converted' }
130
+ if (!(ADJUSTMENT_EDITABLE_STATUSES as readonly string[]).includes(input.cpqStatus)) return { editable: false, reason: 'locked' }
131
+ return { editable: true, reason: null }
132
+ }
133
+
134
+ /** An invalid adjustment can still be deleted after submission, unless the quote is final or converted. */
135
+ export function canDeleteInvalidAdjustment(input: { enabled: boolean | null; canAdjust: boolean; cpqStatus: string; convertedOrderId: string | null }): boolean {
136
+ return input.enabled === true && input.canAdjust && !input.convertedOrderId && !INVALID_DELETE_BLOCKED.includes(input.cpqStatus)
137
+ }
138
+
139
+ /** English fallbacks for server error codes; keys live under `cpq.priceAdjustments.errors.*`. */
140
+ export const ADJUSTMENT_ERROR_FALLBACKS: Record<string, string> = {
141
+ ADJUSTMENT_TARGET_MISSING: 'The adjusted charge no longer exists on this line',
142
+ TARGET_AMBIGUOUS: 'The adjusted charge appears more than once on this line',
143
+ ABOVE_STANDARD: 'The price is above the standard price',
144
+ CURRENCY_MISMATCH: 'The currency does not match the quote',
145
+ TYPE_NOT_ALLOWED: 'This kind of adjustment is not allowed for this charge',
146
+ LINE_NOT_ELIGIBLE: 'This line cannot be adjusted',
147
+ ADJUSTMENT_OVERLAP: 'Another adjustment already targets this charge at the same level',
148
+ ADJUSTMENT_LOCKED: 'Adjustments are locked for this quote',
149
+ STALE_REVISION: 'Prices changed since you opened this — reload and try again',
150
+ REASON_NOT_FOUND: 'Pick a reason from the list',
151
+ REASON_NOTE_REQUIRED: 'Add a note when the reason is “Other”',
152
+ INVALID_PERCENT: 'Enter a percentage above 0 and up to 100',
153
+ INVALID_AMOUNT: 'Enter an amount above 0',
154
+ }
155
+
156
+ export type AdjustmentForm = {
157
+ adjustmentType: AdjustmentType
158
+ /** Raw text from the input; parsed on submit/preview. */
159
+ value: string
160
+ reasonCode: string
161
+ reasonNote: string
162
+ /** Line level only: '' = all billable charges. */
163
+ chargeTypeFilter: '' | 'nrc' | 'mrc'
164
+ }
165
+
166
+ export type AdjustmentTarget =
167
+ | { level: 'charge'; quoteLineId: string; chargeCode: string }
168
+ | { level: 'line'; quoteLineId: string }
169
+
170
+ /** Shape accepted by POST /price-adjustments and the preview `draft`. */
171
+ export function buildAdjustmentDraft(form: AdjustmentForm, target: AdjustmentTarget, currencyCode: string, expectedRevision?: number) {
172
+ const value = Number(form.value.trim().replace(',', '.'))
173
+ if (!form.value.trim() || !Number.isFinite(value)) return null
174
+ const note = form.reasonNote.trim()
175
+ return {
176
+ level: target.level,
177
+ quoteLineId: target.quoteLineId,
178
+ ...(target.level === 'charge' ? { chargeCode: target.chargeCode } : { chargeTypeFilter: form.chargeTypeFilter || null }),
179
+ adjustmentType: form.adjustmentType,
180
+ value,
181
+ ...(form.adjustmentType === 'discount_percent' ? {} : { currencyCode }),
182
+ reasonCode: form.reasonCode,
183
+ ...(note ? { reasonNote: note } : {}),
184
+ ...(expectedRevision !== undefined ? { expectedRevision } : {}),
185
+ }
186
+ }
187
+
188
+ /** PATCH body: target fields are immutable server-side, so they are dropped. */
189
+ export function toAdjustmentPatch(draft: NonNullable<ReturnType<typeof buildAdjustmentDraft>>) {
190
+ const { level: _level, quoteLineId: _line, chargeCode: _charge, ...patch } = draft as typeof draft & { chargeCode?: string }
191
+ return { ...patch, reasonNote: patch.reasonNote ?? null, currencyCode: patch.currencyCode ?? null }
192
+ }
193
+
194
+ /** Server error body → a stable code for messages (`STALE_REVISION`, `INVALID_PERCENT`, …). */
195
+ export function adjustmentErrorCode(status: number, body: unknown): string {
196
+ const record = (body ?? {}) as { error?: unknown; details?: Array<{ message?: unknown }> }
197
+ const detail = Array.isArray(record.details) ? record.details.find((issue) => typeof issue?.message === 'string' && /^[A-Z_]+$/.test(issue.message as string)) : undefined
198
+ if (detail) return detail.message as string
199
+ if (typeof record.error === 'string' && /^[A-Z_]+$/.test(record.error)) return record.error
200
+ if (status === 403) return 'FORBIDDEN'
201
+ if (status === 409) return 'ADJUSTMENT_LOCKED'
202
+ return typeof record.error === 'string' ? record.error : 'UNKNOWN'
203
+ }
204
+
205
+ /** Narrow a stored charge jsonb (subscription / order item) to the waterfall shape; null when unusable. */
206
+ export function parseStoredCharge(raw: unknown): (WaterfallChargeInput & { chargeName: string; quantity: number | null }) | null {
207
+ if (!raw || typeof raw !== 'object') return null
208
+ const record = raw as Record<string, unknown>
209
+ const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
210
+ const chargeType = record.chargeType === 'nrc' || record.chargeType === 'mrc' || record.chargeType === 'usage' ? record.chargeType : null
211
+ const unitPrice = num(record.unitPrice)
212
+ if (!chargeType || unitPrice === undefined) return null
213
+ const adjustments = Array.isArray(record.adjustments)
214
+ ? (record.adjustments as Array<Record<string, unknown>>).filter((entry) =>
215
+ entry && typeof entry === 'object' && typeof entry.ruleType === 'string' && num(entry.unitPriceAfter) !== undefined && num(entry.delta) !== undefined) as unknown as WaterfallChargeInput['adjustments']
216
+ : null
217
+ return {
218
+ chargeName: typeof record.chargeName === 'string' ? record.chargeName : typeof record.chargeCode === 'string' ? record.chargeCode : '—',
219
+ chargeType,
220
+ pricingMethod: typeof record.pricingMethod === 'string' ? record.pricingMethod : null,
221
+ unitPrice,
222
+ quantity: num(record.quantity) ?? null,
223
+ totalPrice: num(record.totalPrice) ?? null,
224
+ listUnitPrice: num(record.listUnitPrice),
225
+ listTotalPrice: num(record.listTotalPrice) ?? null,
226
+ standardUnitPrice: num(record.standardUnitPrice),
227
+ standardTotalPrice: num(record.standardTotalPrice) ?? null,
228
+ adjustments,
229
+ }
230
+ }
231
+
232
+ type ErrorSource = { adjustmentId?: string; code?: string; message?: string }
233
+ export type AdjustmentErrorSourceQuote = {
234
+ adjustmentErrors?: ErrorSource[]
235
+ validationResult?: { errors?: ErrorSource[] } | null
236
+ lines?: Array<{ validationErrors?: ErrorSource[] | null; charges?: Array<{ adjustmentErrors?: ErrorSource[] }> }>
237
+ }
238
+
239
+ /**
240
+ * adjustmentId → first error code. Recalculation responses carry
241
+ * `adjustmentErrors`; a plain GET carries only the persisted validation
242
+ * errors (code in `message`), which is where target-missing errors live.
243
+ */
244
+ export function collectAdjustmentErrors(quote: AdjustmentErrorSourceQuote | null | undefined): Map<string, string> {
245
+ const map = new Map<string, string>()
246
+ const add = (entries: ErrorSource[] | null | undefined) => {
247
+ for (const entry of entries ?? []) {
248
+ const code = entry.code ?? entry.message
249
+ if (entry.adjustmentId && code && !map.has(entry.adjustmentId)) map.set(entry.adjustmentId, code)
250
+ }
251
+ }
252
+ add(quote?.adjustmentErrors)
253
+ for (const line of quote?.lines ?? []) {
254
+ for (const charge of line.charges ?? []) add(charge.adjustmentErrors)
255
+ add(line.validationErrors)
256
+ }
257
+ add(quote?.validationResult?.errors)
258
+ return map
259
+ }
@@ -0,0 +1,22 @@
1
+ import { hasAllFeatures } from '@open-mercato/shared/security/features'
2
+
3
+ /** Feature required to write a quote's prices (recalculate, line edits). */
4
+ export const QUOTE_MANAGE_FEATURE = 'cpq.quotes.manage'
5
+
6
+ /**
7
+ * Terminal quote statuses, mirrored from `TERMINAL_STATUSES` in
8
+ * `services/types.ts` so client components can use this helper without
9
+ * pulling the service layer into the browser bundle. A unit test keeps the
10
+ * two lists identical.
11
+ */
12
+ export const TERMINAL_QUOTE_STATUSES: readonly string[] = ['accepted', 'rejected', 'cancelled']
13
+
14
+ /**
15
+ * Whether the quote page may offer "Recalculate": the user can manage quotes
16
+ * (wildcard-aware) and the quote is not terminal — a saved recalculation on a
17
+ * terminal quote is refused by the API (409).
18
+ */
19
+ export function canRecalculateQuote(grantedFeatures: readonly string[], cpqStatus: string): boolean {
20
+ if (TERMINAL_QUOTE_STATUSES.includes(cpqStatus)) return false
21
+ return hasAllFeatures(grantedFeatures, [QUOTE_MANAGE_FEATURE])
22
+ }