@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
@@ -2,8 +2,11 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../context'
4
4
  import { cpqConvertQuoteToOrderSchema } from '../../data/validators'
5
- import { OrderError } from '../../services/cpqOrderService'
5
+ import { isOrderError } from '../../services/cpqOrderService'
6
6
  import type { DefaultCpqOrderService } from '../../services/cpqOrderService'
7
+ import { CPQ_QUOTE_RESOURCE_KIND, runCpqMutationGuards } from '../_helpers/quoteMutationGuard'
8
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
+ import { cpqErrorDoc } from '../_helpers/quoteRouteOpenApi'
7
10
 
8
11
  export const metadata = {
9
12
  POST: { requireAuth: true, requireFeatures: ['cpq.orders.manage'] },
@@ -18,8 +21,19 @@ export async function POST(req: Request) {
18
21
  const body = cpqConvertQuoteToOrderSchema.parse(await req.json())
19
22
  const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
20
23
 
24
+ // Conversion turns a quote into an order — projects can block it (e.g.
25
+ // until an approval workflow has signed a discounted quote off).
26
+ const guard = await runCpqMutationGuards(ctx, req, {
27
+ resourceKind: CPQ_QUOTE_RESOURCE_KIND,
28
+ resourceId: body.quoteId,
29
+ operation: 'custom',
30
+ mutationPayload: { action: 'convert_to_order' },
31
+ })
32
+ if (!guard.ok) return guard.response
33
+
21
34
  const service = ctx.container.resolve('cpqOrderService') as DefaultCpqOrderService
22
35
  const result = await service.convertQuoteToOrder(body.quoteId, scope)
36
+ await guard.runAfterSuccess()
23
37
  return NextResponse.json(result, { status: 201 })
24
38
  } catch (err) {
25
39
  return handleError(err, 'cpq/orders.POST')
@@ -65,8 +79,8 @@ function handleError(err: unknown, context: string) {
65
79
  if (err instanceof z.ZodError) {
66
80
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
67
81
  }
68
- if (err instanceof OrderError) {
69
- return NextResponse.json({ error: err.message }, { status: err.status })
82
+ if (isOrderError(err)) {
83
+ return NextResponse.json({ error: err.message, ...err.details }, { status: err.status })
70
84
  }
71
85
  const errObj = err as Record<string, unknown>
72
86
  if (typeof errObj?.status === 'number' && typeof errObj?.error === 'string') {
@@ -75,3 +89,39 @@ function handleError(err: unknown, context: string) {
75
89
  console.error(`[${context}]`, err)
76
90
  return NextResponse.json({ error: 'Internal error' }, { status: 500 })
77
91
  }
92
+
93
+ export const openApi: OpenApiRouteDoc = {
94
+ tag: 'CPQ Orders',
95
+ summary: 'CPQ orders',
96
+ methods: {
97
+ POST: {
98
+ summary: 'Convert a CPQ quote into an order',
99
+ description:
100
+ 'Creates the sales order and CPQ order configuration from the quote. Refused for cancelled or rejected quotes; mutation guards (resource kind `cpq.quote`, action `convert_to_order`) run first.',
101
+ requestBody: { schema: cpqConvertQuoteToOrderSchema },
102
+ responses: [{ status: 201, description: 'Created order' }],
103
+ errors: [
104
+ cpqErrorDoc(400, 'Validation error'),
105
+ cpqErrorDoc(404, 'Quote not found'),
106
+ cpqErrorDoc(409, 'Quote cannot be converted in its status or is already converted'),
107
+ cpqErrorDoc(422, 'Quote has no lines or has invalid stored adjustments, or blocked by a mutation guard'),
108
+ ],
109
+ },
110
+ GET: {
111
+ summary: 'List CPQ orders',
112
+ query: z.object({
113
+ customerId: z.string().uuid().optional(),
114
+ sourceQuoteId: z.string().uuid().optional(),
115
+ salesOrderId: z.string().uuid().optional(),
116
+ cpqStatus: z.string().optional(),
117
+ currencyCode: z.string().optional(),
118
+ search: z.string().optional(),
119
+ sortField: z.enum(['createdAt', 'updatedAt', 'cpqStatus', 'activatedAt', 'currencyCode']).optional(),
120
+ sortDir: z.enum(['asc', 'desc']).optional(),
121
+ page: z.coerce.number().int().min(1).optional(),
122
+ pageSize: z.coerce.number().int().min(1).max(100).optional(),
123
+ }),
124
+ responses: [{ status: 200, description: 'Paged list of CPQ orders' }],
125
+ },
126
+ },
127
+ }
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { resolveCpqRouteContext } from '../../../context'
3
- import { QuotingError } from '../../../../services/cpqQuotingService'
3
+ import { isQuotingError } from '../../../../services/cpqQuotingService'
4
4
  import { resolveQuotingService } from '../../../resolveQuotingService'
5
5
 
6
6
  export const metadata = {
@@ -19,7 +19,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
19
19
  const result = await service.cloneQuote(quoteId, scope)
20
20
  return NextResponse.json(result, { status: 201 })
21
21
  } catch (err) {
22
- if (err instanceof QuotingError) {
22
+ if (isQuotingError(err)) {
23
23
  return NextResponse.json({ error: err.message }, { status: err.status })
24
24
  }
25
25
  console.error('[cpq/quotes/[quoteId]/clone.POST]', err)
@@ -0,0 +1,56 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { resolveCpqRouteContext } from '../../../../../context'
4
+ import { isQuotingError } from '../../../../../../services/cpqQuotingService'
5
+ import { resolveQuotingService } from '../../../../../resolveQuotingService'
6
+ import { CPQ_QUOTE_LINE_RESOURCE_KIND, runCpqMutationGuards } from '../../../../../_helpers/quoteMutationGuard'
7
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
8
+ import { CPQ_GUARDED_WRITE_ERRORS } from '../../../../../_helpers/quoteRouteOpenApi'
9
+
10
+ export const metadata = {
11
+ POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
12
+ }
13
+
14
+ export async function POST(req: Request, { params }: { params: Promise<{ quoteId: string; lineId: string }> }) {
15
+ try {
16
+ const ctx = await resolveCpqRouteContext(req)
17
+ if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18
+
19
+ const { quoteId, lineId } = await params
20
+ const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
21
+
22
+ const guard = await runCpqMutationGuards(ctx, req, {
23
+ resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
24
+ resourceId: lineId,
25
+ operation: 'update',
26
+ mutationPayload: { quoteId, restore: true },
27
+ })
28
+ if (!guard.ok) return guard.response
29
+
30
+ const service = resolveQuotingService(ctx)
31
+ const result = await service.restoreQuoteItem(quoteId, lineId, scope)
32
+ await guard.runAfterSuccess()
33
+ return NextResponse.json(result)
34
+ } catch (err) {
35
+ if (isQuotingError(err)) {
36
+ return NextResponse.json({ error: err.message }, { status: err.status })
37
+ }
38
+ console.error('[cpq/quotes/[quoteId]/items/[lineId]/restore.POST]', err)
39
+ return NextResponse.json({ error: 'Internal error' }, { status: 500 })
40
+ }
41
+ }
42
+
43
+ export const openApi: OpenApiRouteDoc = {
44
+ tag: 'CPQ Quotes',
45
+ summary: 'Restore a subscription item marked for removal',
46
+ pathParams: z.object({ quoteId: z.string().uuid(), lineId: z.string().uuid() }),
47
+ methods: {
48
+ POST: {
49
+ summary: 'Restore a removed subscription item on an Amend / Renew quote',
50
+ description:
51
+ 'Turns a `cancel` line that mirrors a live subscription item back into a `modify` line, rebuilt from the item (configuration, quantity, stored price). Its cancelled mirrored components are restored with it. 409 when the line is not a removed item, its parent is still removed, or the item is no longer active. Mutation guards run first (resource kind `cpq.quote_line`, update).',
52
+ responses: [{ status: 200, description: 'Quote after the restore' }],
53
+ errors: CPQ_GUARDED_WRITE_ERRORS.filter((doc) => doc.status !== 400),
54
+ },
55
+ },
56
+ }
@@ -2,8 +2,11 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../../../../context'
4
4
  import { cpqUpdateQuoteItemSchema } from '../../../../../data/validators'
5
- import { QuotingError } from '../../../../../services/cpqQuotingService'
5
+ import { isQuotingError } from '../../../../../services/cpqQuotingService'
6
6
  import { resolveQuotingService } from '../../../../resolveQuotingService'
7
+ import { CPQ_QUOTE_LINE_RESOURCE_KIND, runCpqMutationGuards } from '../../../../_helpers/quoteMutationGuard'
8
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
+ import { CPQ_GUARDED_WRITE_ERRORS } from '../../../../_helpers/quoteRouteOpenApi'
7
10
 
8
11
  export const metadata = {
9
12
  PUT: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
@@ -19,14 +22,26 @@ export async function PUT(req: Request, { params }: { params: Promise<{ quoteId:
19
22
  const body = cpqUpdateQuoteItemSchema.parse(await req.json())
20
23
  const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
21
24
 
25
+ const guard = await runCpqMutationGuards(ctx, req, {
26
+ resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
27
+ resourceId: lineId,
28
+ operation: 'update',
29
+ mutationPayload: { quoteId, ...body },
30
+ })
31
+ if (!guard.ok) return guard.response
32
+ const effective = guard.modifiedPayload
33
+ ? cpqUpdateQuoteItemSchema.parse({ ...body, ...guard.modifiedPayload })
34
+ : body
35
+
22
36
  const service = resolveQuotingService(ctx)
23
- const result = await service.updateQuoteItem(quoteId, lineId, body, scope)
37
+ const result = await service.updateQuoteItem(quoteId, lineId, effective, scope)
38
+ await guard.runAfterSuccess()
24
39
  return NextResponse.json(result)
25
40
  } catch (err) {
26
41
  if (err instanceof z.ZodError) {
27
42
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
28
43
  }
29
- if (err instanceof QuotingError) {
44
+ if (isQuotingError(err)) {
30
45
  return NextResponse.json({ error: err.message }, { status: err.status })
31
46
  }
32
47
  console.error('[cpq/quotes/[quoteId]/items/[lineId].PUT]', err)
@@ -42,14 +57,46 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ quote
42
57
  const { quoteId, lineId } = await params
43
58
  const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
44
59
 
60
+ const guard = await runCpqMutationGuards(ctx, req, {
61
+ resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
62
+ resourceId: lineId,
63
+ operation: 'delete',
64
+ mutationPayload: { quoteId },
65
+ })
66
+ if (!guard.ok) return guard.response
67
+
45
68
  const service = resolveQuotingService(ctx)
46
69
  const result = await service.removeQuoteItem(quoteId, lineId, scope)
70
+ await guard.runAfterSuccess()
47
71
  return NextResponse.json(result)
48
72
  } catch (err) {
49
- if (err instanceof QuotingError) {
73
+ if (isQuotingError(err)) {
50
74
  return NextResponse.json({ error: err.message }, { status: err.status })
51
75
  }
52
76
  console.error('[cpq/quotes/[quoteId]/items/[lineId].DELETE]', err)
53
77
  return NextResponse.json({ error: 'Internal error' }, { status: 500 })
54
78
  }
55
79
  }
80
+
81
+ export const openApi: OpenApiRouteDoc = {
82
+ tag: 'CPQ Quotes',
83
+ summary: 'CPQ quote line',
84
+ pathParams: z.object({ quoteId: z.string().uuid(), lineId: z.string().uuid() }),
85
+ methods: {
86
+ PUT: {
87
+ summary: 'Update a quote line (configuration, quantity, term)',
88
+ description:
89
+ 'Re-prices the line unless it is a frozen Amend mirror line whose pricing inputs are unchanged. Mutation guards run first (resource kind `cpq.quote_line`, update). `_arc*` configuration keys are server-owned and ignored.',
90
+ requestBody: { schema: cpqUpdateQuoteItemSchema },
91
+ responses: [{ status: 200, description: 'Quote after the update' }],
92
+ errors: CPQ_GUARDED_WRITE_ERRORS,
93
+ },
94
+ DELETE: {
95
+ summary: 'Remove a quote line',
96
+ description:
97
+ 'On an Amend / Renew quote, a line mirroring a live subscription item is not deleted: it (and its mirrored components) becomes a `cancel` line with zero totals, and activation terminates the item; lines this quote added under it are deleted. 409 when the item is already marked for removal — use `POST .../restore` to undo. Other lines are deleted with their descendants. Mutation guards run first (resource kind `cpq.quote_line`, delete).',
98
+ responses: [{ status: 200, description: 'Quote after the removal' }],
99
+ errors: CPQ_GUARDED_WRITE_ERRORS.filter((doc) => doc.status !== 400),
100
+ },
101
+ },
102
+ }
@@ -3,6 +3,9 @@ import { resolveCpqRouteContext } from '../../../context'
3
3
  import { cpqAddQuoteItemSchema } from '../../../../data/validators'
4
4
  import { resolveQuotingService } from '../../../resolveQuotingService'
5
5
  import { handleArcError } from '../../../_helpers/handleArcError'
6
+ import { CPQ_QUOTE_LINE_RESOURCE_KIND, runCpqMutationGuards } from '../../../_helpers/quoteMutationGuard'
7
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
8
+ import { CPQ_GUARDED_WRITE_ERRORS, cpqQuoteIdPathParams } from '../../../_helpers/quoteRouteOpenApi'
6
9
 
7
10
  export const metadata = {
8
11
  POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
@@ -17,10 +20,37 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
17
20
  const body = cpqAddQuoteItemSchema.parse(await req.json())
18
21
  const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
19
22
 
23
+ const guard = await runCpqMutationGuards(ctx, req, {
24
+ resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
25
+ operation: 'create',
26
+ mutationPayload: { quoteId, ...body },
27
+ })
28
+ if (!guard.ok) return guard.response
29
+ const effective = guard.modifiedPayload
30
+ ? cpqAddQuoteItemSchema.parse({ ...body, ...guard.modifiedPayload })
31
+ : body
32
+
20
33
  const service = resolveQuotingService(ctx)
21
- const result = await service.addQuoteItem(quoteId, body, scope)
34
+ const result = await service.addQuoteItem(quoteId, effective, scope)
35
+ await guard.runAfterSuccess()
22
36
  return NextResponse.json(result, { status: 201 })
23
37
  } catch (err) {
24
38
  return handleArcError(err, 'cpq/quotes/[quoteId]/items.POST')
25
39
  }
26
40
  }
41
+
42
+ export const openApi: OpenApiRouteDoc = {
43
+ tag: 'CPQ Quotes',
44
+ summary: 'CPQ quote lines',
45
+ pathParams: cpqQuoteIdPathParams,
46
+ methods: {
47
+ POST: {
48
+ summary: 'Add a configured line to a CPQ quote',
49
+ description:
50
+ 'Prices the line and recalculates the quote. Mutation guards run first (resource kind `cpq.quote_line`, create). `_arc*` configuration keys are server-owned and ignored.',
51
+ requestBody: { schema: cpqAddQuoteItemSchema },
52
+ responses: [{ status: 201, description: 'Quote after the line was added' }],
53
+ errors: CPQ_GUARDED_WRITE_ERRORS,
54
+ },
55
+ },
56
+ }
@@ -0,0 +1,39 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
+ import { resolveCpqRouteContext } from '../../../../context'
5
+ import { cpqPriceAdjustmentPatchSchema } from '../../../../../data/price-adjustments'
6
+ import { adjustmentCommandRoute } from '../../../../_helpers/adjustmentCommandRoute'
7
+ import { handleArcError } from '../../../../_helpers/handleArcError'
8
+ import { cpqErrorDoc } from '../../../../_helpers/quoteRouteOpenApi'
9
+
10
+ export const metadata = {
11
+ PATCH: { requireAuth: true, requireFeatures: ['cpq.quotes.manage', 'cpq.quotes.adjust_price'] },
12
+ DELETE: { requireAuth: true, requireFeatures: ['cpq.quotes.manage', 'cpq.quotes.adjust_price'] },
13
+ }
14
+ const pathParams = z.object({ quoteId: z.string().uuid(), adjustmentId: z.string().uuid() })
15
+ const deletionSchema = z.object({ expectedRevision: z.coerce.number().int().min(0).optional() }).strict()
16
+ type Params = { params: Promise<{ quoteId: string; adjustmentId: string }> }
17
+ export async function PATCH(req: Request, { params }: Params) {
18
+ try {
19
+ const ctx = await resolveCpqRouteContext(req)
20
+ if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21
+ const { quoteId, adjustmentId } = pathParams.parse(await params)
22
+ const body = cpqPriceAdjustmentPatchSchema.parse(await req.json())
23
+ return await adjustmentCommandRoute(req, ctx, quoteId, body, cpqPriceAdjustmentPatchSchema, 'update', adjustmentId)
24
+ } catch (error) { return handleArcError(error, 'cpq/price-adjustments.PATCH') }
25
+ }
26
+ export async function DELETE(req: Request, { params }: Params) {
27
+ try {
28
+ const ctx = await resolveCpqRouteContext(req)
29
+ if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
30
+ const { quoteId, adjustmentId } = pathParams.parse(await params)
31
+ const body = deletionSchema.parse(Object.fromEntries(new URL(req.url).searchParams))
32
+ return await adjustmentCommandRoute(req, ctx, quoteId, body, deletionSchema, 'delete', adjustmentId)
33
+ } catch (error) { return handleArcError(error, 'cpq/price-adjustments.DELETE') }
34
+ }
35
+ const errors = [cpqErrorDoc(400, 'Invalid input'), cpqErrorDoc(403, 'Missing permission or disabled feature'), cpqErrorDoc(404, 'Quote or adjustment not found'), cpqErrorDoc(409, 'Adjustment locked, stale revision or changed undo target'), cpqErrorDoc(422, 'Invalid adjustment or guard rejection')]
36
+ export const openApi: OpenApiRouteDoc = { tag: 'CPQ Quotes', pathParams, methods: {
37
+ PATCH: { summary: 'Update a negotiated quote price adjustment', requestBody: { schema: cpqPriceAdjustmentPatchSchema }, responses: [{ status: 200, description: 'Adjustment and updated quote' }], errors },
38
+ DELETE: { summary: 'Remove a negotiated quote price adjustment', query: deletionSchema, responses: [{ status: 200, description: 'Updated quote' }], errors },
39
+ } }
@@ -0,0 +1,24 @@
1
+ import { NextResponse } from 'next/server'
2
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
3
+ import { resolveCpqRouteContext } from '../../../../context'
4
+ import { cpqPriceAdjustmentPreviewSchema } from '../../../../../data/price-adjustments'
5
+ import type { CpqAdjustmentService } from '../../../../../services/priceAdjustments/adjustmentService'
6
+ import { handleArcError } from '../../../../_helpers/handleArcError'
7
+ import { cpqErrorDoc, cpqQuoteIdPathParams } from '../../../../_helpers/quoteRouteOpenApi'
8
+
9
+ export const metadata = { POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage', 'cpq.quotes.adjust_price'] } }
10
+ export async function POST(req: Request, { params }: { params: Promise<{ quoteId: string }> }) {
11
+ try {
12
+ const ctx = await resolveCpqRouteContext(req)
13
+ if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
14
+ const { quoteId } = cpqQuoteIdPathParams.parse(await params)
15
+ const body = cpqPriceAdjustmentPreviewSchema.parse(await req.json())
16
+ const service = ctx.container.resolve<CpqAdjustmentService>('cpqAdjustmentService')
17
+ return NextResponse.json(await service.preview(quoteId, body.draft, body.replaceAdjustmentId, { tenantId: ctx.tenantId, organizationId: ctx.organizationId }))
18
+ } catch (error) { return handleArcError(error, 'cpq/price-adjustments/preview.POST') }
19
+ }
20
+ export const openApi: OpenApiRouteDoc = { tag: 'CPQ Quotes', pathParams: cpqQuoteIdPathParams, methods: { POST: {
21
+ summary: 'Preview a draft adjustment without saving or emitting events', requestBody: { schema: cpqPriceAdjustmentPreviewSchema },
22
+ responses: [{ status: 200, description: 'Draft quote and adjustment errors; nothing persisted' }],
23
+ errors: [cpqErrorDoc(400, 'Invalid draft'), cpqErrorDoc(403, 'Missing permission or disabled feature'), cpqErrorDoc(404, 'Quote, line or replaced adjustment not found'), cpqErrorDoc(409, 'Adjustment locked or stale revision'), cpqErrorDoc(422, 'Ineligible target or invalid reason')],
24
+ } } }
@@ -0,0 +1,40 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
+ import { resolveCpqRouteContext } from '../../../context'
5
+ import { cpqPriceAdjustmentInputSchema } from '../../../../data/price-adjustments'
6
+ import type { CpqAdjustmentService } from '../../../../services/priceAdjustments/adjustmentService'
7
+ import { adjustmentCommandRoute } from '../../../_helpers/adjustmentCommandRoute'
8
+ import { handleArcError } from '../../../_helpers/handleArcError'
9
+ import { cpqErrorDoc, cpqQuoteIdPathParams } from '../../../_helpers/quoteRouteOpenApi'
10
+
11
+ export const metadata = {
12
+ GET: { requireAuth: true, requireFeatures: ['cpq.quotes.view'] },
13
+ POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage', 'cpq.quotes.adjust_price'] },
14
+ }
15
+ const listQuery = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(25) })
16
+ type Params = { params: Promise<{ quoteId: string }> }
17
+ export async function GET(req: Request, { params }: Params) {
18
+ try {
19
+ const ctx = await resolveCpqRouteContext(req)
20
+ if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21
+ const { quoteId } = cpqQuoteIdPathParams.parse(await params)
22
+ const { page, pageSize } = listQuery.parse(Object.fromEntries(new URL(req.url).searchParams))
23
+ const service = ctx.container.resolve<CpqAdjustmentService>('cpqAdjustmentService')
24
+ return NextResponse.json(await service.list(quoteId, { organizationId: ctx.organizationId, tenantId: ctx.tenantId }, page, pageSize))
25
+ } catch (error) { return handleArcError(error, 'cpq/price-adjustments.GET') }
26
+ }
27
+ export async function POST(req: Request, { params }: Params) {
28
+ try {
29
+ const ctx = await resolveCpqRouteContext(req)
30
+ if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
31
+ const { quoteId } = cpqQuoteIdPathParams.parse(await params)
32
+ const body = cpqPriceAdjustmentInputSchema.parse(await req.json())
33
+ return await adjustmentCommandRoute(req, ctx, quoteId, body, cpqPriceAdjustmentInputSchema, 'create')
34
+ } catch (error) { return handleArcError(error, 'cpq/price-adjustments.POST') }
35
+ }
36
+ const errors = [cpqErrorDoc(400, 'Invalid input'), cpqErrorDoc(403, 'Missing permission or disabled feature'), cpqErrorDoc(404, 'Quote not found'), cpqErrorDoc(409, 'Adjustment locked or stale revision'), cpqErrorDoc(422, 'Invalid adjustment or guard rejection')]
37
+ export const openApi: OpenApiRouteDoc = { tag: 'CPQ Quotes', pathParams: cpqQuoteIdPathParams, methods: {
38
+ GET: { summary: 'List quote price adjustments', query: listQuery, responses: [{ status: 200, description: 'Paged adjustments, pricing revision, feature state and the reason options (`reasons: [{ value, label }]`)' }], errors },
39
+ POST: { summary: 'Create a negotiated quote price adjustment', requestBody: { schema: cpqPriceAdjustmentInputSchema }, responses: [{ status: 201, description: 'Adjustment and updated quote' }], errors },
40
+ } }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Recalculate route contract.
3
+ *
4
+ * Route-level `requireFeatures` is enforced by the API dispatcher, so the
5
+ * permission contract is pinned through the exported `metadata`; the handler
6
+ * contract (body → guard → service call) is pinned with mocked collaborators.
7
+ */
8
+
9
+ const recalculate = jest.fn()
10
+ const runCpqMutationGuards = jest.fn()
11
+ const runAfterSuccess = jest.fn()
12
+
13
+ jest.mock('../../../../context', () => ({
14
+ resolveCpqRouteContext: jest.fn().mockResolvedValue({
15
+ auth: { sub: 'user-1', tenantId: 't-1' },
16
+ organizationId: 'org-1',
17
+ tenantId: 't-1',
18
+ }),
19
+ }))
20
+
21
+ jest.mock('../../../../resolveQuotingService', () => ({
22
+ resolveQuotingService: () => ({ recalculate }),
23
+ }))
24
+
25
+ jest.mock('../../../../_helpers/quoteMutationGuard', () => ({
26
+ CPQ_QUOTE_RESOURCE_KIND: 'cpq.quote',
27
+ runCpqMutationGuards: (...args: unknown[]) => runCpqMutationGuards(...args),
28
+ }))
29
+
30
+ import { metadata, openApi, POST } from '../route'
31
+
32
+ function postRequest(body: unknown): Request {
33
+ return new Request('http://localhost/api/cpq/quotes/cpq-1/recalculate', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify(body),
37
+ })
38
+ }
39
+
40
+ const params = { params: Promise.resolve({ quoteId: 'cpq-1' }) }
41
+
42
+ describe('POST /api/cpq/quotes/{quoteId}/recalculate', () => {
43
+ beforeEach(() => {
44
+ recalculate.mockReset()
45
+ recalculate.mockResolvedValue({ id: 'cpq-1' })
46
+ runAfterSuccess.mockReset()
47
+ runCpqMutationGuards.mockReset()
48
+ runCpqMutationGuards.mockResolvedValue({ ok: true, runAfterSuccess })
49
+ })
50
+
51
+ it('requires an authenticated user with cpq.quotes.manage (view-only users get 403 from the dispatcher)', () => {
52
+ expect(metadata.POST).toEqual({ requireAuth: true, requireFeatures: ['cpq.quotes.manage'] })
53
+ })
54
+
55
+ it('documents the route in openApi', () => {
56
+ expect(openApi.methods?.POST?.errors).toEqual(expect.arrayContaining([expect.objectContaining({ status: 409 })]))
57
+ expect(openApi.methods?.POST?.requestBody).toBeDefined()
58
+ })
59
+
60
+ it('runs the mutation guards before a saved recalculation and their after-success hooks after it', async () => {
61
+ const res = await POST(postRequest({ save: true }), params)
62
+
63
+ expect(res.status).toBe(200)
64
+ expect(runCpqMutationGuards).toHaveBeenCalledWith(
65
+ expect.anything(),
66
+ expect.any(Request),
67
+ expect.objectContaining({ resourceKind: 'cpq.quote', resourceId: 'cpq-1', operation: 'custom' }),
68
+ )
69
+ expect(recalculate).toHaveBeenCalledWith(
70
+ 'cpq-1',
71
+ expect.objectContaining({ save: true }),
72
+ { organizationId: 'org-1', tenantId: 't-1' },
73
+ )
74
+ expect(runAfterSuccess).toHaveBeenCalledTimes(1)
75
+ })
76
+
77
+ it('skips the guards for a dry run (save=false)', async () => {
78
+ await POST(postRequest({ save: false }), params)
79
+
80
+ expect(runCpqMutationGuards).not.toHaveBeenCalled()
81
+ expect(recalculate).toHaveBeenCalled()
82
+ })
83
+
84
+ it('returns the guard response and does not recalculate when a guard blocks', async () => {
85
+ runCpqMutationGuards.mockResolvedValue({
86
+ ok: false,
87
+ response: Response.json({ error: 'Quote awaits approval' }, { status: 423 }),
88
+ })
89
+
90
+ const res = await POST(postRequest({ save: true }), params)
91
+
92
+ expect(res.status).toBe(423)
93
+ expect(recalculate).not.toHaveBeenCalled()
94
+ })
95
+ })
@@ -2,11 +2,17 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../../../context'
4
4
  import { cpqRecalculateSchema } from '../../../../data/validators'
5
- import { QuotingError } from '../../../../services/cpqQuotingService'
5
+ import { isQuotingError } from '../../../../services/cpqQuotingService'
6
6
  import { resolveQuotingService } from '../../../resolveQuotingService'
7
+ import { CPQ_QUOTE_RESOURCE_KIND, runCpqMutationGuards } from '../../../_helpers/quoteMutationGuard'
8
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
9
+ import { cpqErrorDoc, cpqQuoteIdPathParams } from '../../../_helpers/quoteRouteOpenApi'
7
10
 
11
+ // Recalculation re-resolves and (with `save: true`) persists prices, so it
12
+ // needs the same feature as any other quote write. The quote page always
13
+ // saves; viewers read stored prices through GET /api/cpq/quotes/{quoteId}.
8
14
  export const metadata = {
9
- POST: { requireAuth: true, requireFeatures: ['cpq.quotes.view'] },
15
+ POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
10
16
  }
11
17
 
12
18
  export async function POST(req: Request, { params }: { params: Promise<{ quoteId: string }> }) {
@@ -18,17 +24,50 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
18
24
  const body = cpqRecalculateSchema.parse(await req.json())
19
25
  const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
20
26
 
27
+ // Only a saved recalculation writes; a dry run needs no guard.
28
+ const guard = body.save
29
+ ? await runCpqMutationGuards(ctx, req, {
30
+ resourceKind: CPQ_QUOTE_RESOURCE_KIND,
31
+ resourceId: quoteId,
32
+ operation: 'custom',
33
+ mutationPayload: { action: 'recalculate' },
34
+ })
35
+ : null
36
+ if (guard && !guard.ok) return guard.response
37
+
21
38
  const service = resolveQuotingService(ctx)
22
39
  const result = await service.recalculate(quoteId, body, scope)
40
+ if (guard) await guard.runAfterSuccess()
23
41
  return NextResponse.json(result)
24
42
  } catch (err) {
25
43
  if (err instanceof z.ZodError) {
26
44
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
27
45
  }
28
- if (err instanceof QuotingError) {
46
+ if (isQuotingError(err)) {
29
47
  return NextResponse.json({ error: err.message }, { status: err.status })
30
48
  }
31
49
  console.error('[cpq/quotes/[quoteId]/recalculate.POST]', err)
32
50
  return NextResponse.json({ error: 'Internal error' }, { status: 500 })
33
51
  }
34
52
  }
53
+
54
+ export const openApi: OpenApiRouteDoc = {
55
+ tag: 'CPQ Quotes',
56
+ summary: 'CPQ quote recalculation',
57
+ pathParams: cpqQuoteIdPathParams,
58
+ methods: {
59
+ POST: {
60
+ summary: 'Re-price every line of a CPQ quote',
61
+ description:
62
+ 'Re-resolves charges and price rules. With `save: true` the result is persisted (refused on accepted, rejected and cancelled quotes) and mutation guards (resource kind `cpq.quote`, action `recalculate`) run first. Frozen Amend mirror lines keep their stored price.',
63
+ requestBody: { schema: cpqRecalculateSchema },
64
+ responses: [{ status: 200, description: 'Recalculated quote' }],
65
+ errors: [
66
+ cpqErrorDoc(400, 'Validation error'),
67
+ cpqErrorDoc(404, 'Quote not found'),
68
+ cpqErrorDoc(409, 'Saved recalculation on a terminal quote'),
69
+ cpqErrorDoc(422, 'Blocked by a mutation guard'),
70
+ ],
71
+ },
72
+ },
73
+ }
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { resolveCpqRouteContext } from '../../context'
3
3
  import { cpqUpdateQuoteSchema } from '../../../data/validators'
4
- import { QuotingError } from '../../../services/cpqQuotingService'
4
+ import { isQuotingError } from '../../../services/cpqQuotingService'
5
5
  import { resolveQuotingService } from '../../resolveQuotingService'
6
6
  import { CpqQuoteConfiguration, CpqQuoteLineConfiguration } from '../../../data/entities'
7
7
  import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
@@ -25,7 +25,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ quoteId:
25
25
  const result = await service.getQuote(quoteId, scope)
26
26
  return NextResponse.json(result)
27
27
  } catch (err) {
28
- if (err instanceof QuotingError) {
28
+ if (isQuotingError(err)) {
29
29
  return NextResponse.json({ error: err.message }, { status: err.status })
30
30
  }
31
31
  console.error('[cpq/quotes/[quoteId].GET]', err)