@dainamite/cpq 0.5.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 (233) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/MIGRATION.md +74 -0
  3. package/dist/modules/cpq/acl.js +1 -0
  4. package/dist/modules/cpq/acl.js.map +2 -2
  5. package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js +45 -0
  6. package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js.map +7 -0
  7. package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js +23 -0
  8. package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js.map +7 -0
  9. package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js +19 -0
  10. package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js.map +7 -0
  11. package/dist/modules/cpq/api/orders/[orderId]/route.js +17 -1
  12. package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
  13. package/dist/modules/cpq/api/orders/route.js +47 -2
  14. package/dist/modules/cpq/api/orders/route.js.map +2 -2
  15. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js +54 -0
  16. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js.map +7 -0
  17. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +42 -2
  18. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
  19. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -2
  20. package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +2 -2
  21. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js +47 -0
  22. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js.map +7 -0
  23. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js +30 -0
  24. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js.map +7 -0
  25. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js +47 -0
  26. package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js.map +7 -0
  27. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +32 -2
  28. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
  29. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +33 -2
  30. package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +2 -2
  31. package/dist/modules/cpq/api/quotes/price/route.js +43 -3
  32. package/dist/modules/cpq/api/quotes/price/route.js.map +2 -2
  33. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +1 -1
  34. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +1 -1
  35. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +22 -8
  36. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
  37. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +89 -232
  38. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
  39. package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
  40. package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +1 -1
  41. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +205 -0
  42. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
  43. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
  44. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
  45. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
  46. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
  47. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
  48. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
  49. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
  50. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
  51. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +144 -221
  52. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
  53. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
  54. package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +1 -1
  55. package/dist/modules/cpq/ce.js +8 -0
  56. package/dist/modules/cpq/ce.js.map +2 -2
  57. package/dist/modules/cpq/cli.js +34 -1
  58. package/dist/modules/cpq/cli.js.map +2 -2
  59. package/dist/modules/cpq/commands/price-adjustments.js +107 -0
  60. package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
  61. package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
  62. package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
  63. package/dist/modules/cpq/components/ChargeBreakdownTable.js +154 -0
  64. package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
  65. package/dist/modules/cpq/components/PriceWaterfall.js +66 -0
  66. package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
  67. package/dist/modules/cpq/components/PricingSummaryCard.js +117 -0
  68. package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
  69. package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
  70. package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
  71. package/dist/modules/cpq/data/entities.js +97 -0
  72. package/dist/modules/cpq/data/entities.js.map +2 -2
  73. package/dist/modules/cpq/data/price-adjustments.js +70 -0
  74. package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
  75. package/dist/modules/cpq/data/validators.js +4 -0
  76. package/dist/modules/cpq/data/validators.js.map +2 -2
  77. package/dist/modules/cpq/di.js +6 -0
  78. package/dist/modules/cpq/di.js.map +2 -2
  79. package/dist/modules/cpq/events.js +47 -0
  80. package/dist/modules/cpq/events.js.map +2 -2
  81. package/dist/modules/cpq/index.js +1 -1
  82. package/dist/modules/cpq/index.js.map +2 -2
  83. package/dist/modules/cpq/lib/auditActor.js +10 -0
  84. package/dist/modules/cpq/lib/auditActor.js.map +7 -0
  85. package/dist/modules/cpq/lib/formatMoney.js +7 -0
  86. package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
  87. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
  88. package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
  89. package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
  90. package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
  91. package/dist/modules/cpq/lib/quotePermissions.js +13 -0
  92. package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
  93. package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
  94. package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
  95. package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
  96. package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
  97. package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
  98. package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
  99. package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
  100. package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
  101. package/dist/modules/cpq/services/cpqOrderService.js +25 -28
  102. package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
  103. package/dist/modules/cpq/services/cpqPricingService.js +7 -0
  104. package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
  105. package/dist/modules/cpq/services/cpqQuotingService.js +330 -109
  106. package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
  107. package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
  108. package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
  109. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
  110. package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
  111. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
  112. package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
  113. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
  114. package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
  115. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
  116. package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
  117. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
  118. package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
  119. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
  120. package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
  121. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
  122. package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
  123. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
  124. package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
  125. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
  126. package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
  127. package/dist/modules/cpq/services/types.js.map +2 -2
  128. package/dist/modules/cpq/setup.js +6 -0
  129. package/dist/modules/cpq/setup.js.map +2 -2
  130. package/package.json +1 -1
  131. package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
  132. package/src/modules/cpq/acl.ts +1 -0
  133. package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
  134. package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
  135. package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
  136. package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
  137. package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
  138. package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
  139. package/src/modules/cpq/api/orders/[orderId]/route.ts +18 -0
  140. package/src/modules/cpq/api/orders/route.ts +51 -1
  141. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
  142. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +48 -1
  143. package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
  144. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
  145. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
  146. package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
  147. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
  148. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +40 -1
  149. package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
  150. package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
  151. package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
  152. package/src/modules/cpq/api/quotes/price/route.ts +28 -2
  153. package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +1 -1
  154. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +32 -10
  155. package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +60 -269
  156. package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
  157. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +260 -0
  158. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
  159. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
  160. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
  161. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
  162. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
  163. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +174 -263
  164. package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
  165. package/src/modules/cpq/ce.ts +8 -0
  166. package/src/modules/cpq/cli.ts +48 -1
  167. package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
  168. package/src/modules/cpq/commands/price-adjustments.ts +90 -0
  169. package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
  170. package/src/modules/cpq/components/ChargeBreakdownTable.tsx +201 -0
  171. package/src/modules/cpq/components/PriceWaterfall.tsx +94 -0
  172. package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
  173. package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
  174. package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
  175. package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
  176. package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
  177. package/src/modules/cpq/data/entities.ts +83 -0
  178. package/src/modules/cpq/data/price-adjustments.ts +67 -0
  179. package/src/modules/cpq/data/validators.ts +3 -0
  180. package/src/modules/cpq/di.ts +6 -0
  181. package/src/modules/cpq/events.ts +53 -2
  182. package/src/modules/cpq/index.ts +1 -1
  183. package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
  184. package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
  185. package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
  186. package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
  187. package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
  188. package/src/modules/cpq/lib/auditActor.ts +13 -0
  189. package/src/modules/cpq/lib/formatMoney.ts +4 -0
  190. package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
  191. package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
  192. package/src/modules/cpq/lib/quotePermissions.ts +22 -0
  193. package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
  194. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
  195. package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
  196. package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
  197. package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
  198. package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
  199. package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
  200. package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
  201. package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
  202. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
  203. package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
  204. package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
  205. package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
  206. package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
  207. package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
  208. package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
  209. package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
  210. package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
  211. package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
  212. package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
  213. package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
  214. package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
  215. package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
  216. package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
  217. package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
  218. package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
  219. package/src/modules/cpq/services/cpqOrderService.ts +22 -28
  220. package/src/modules/cpq/services/cpqPricingService.ts +8 -0
  221. package/src/modules/cpq/services/cpqQuotingService.ts +374 -125
  222. package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
  223. package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
  224. package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
  225. package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
  226. package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
  227. package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
  228. package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
  229. package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
  230. package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
  231. package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
  232. package/src/modules/cpq/services/types.ts +34 -1
  233. package/src/modules/cpq/setup.ts +7 -1
@@ -1,3 +1,12 @@
1
+ import { cloneQuoteAdjustments, removeQuoteLineTree } from './priceAdjustments/quoteAdjustmentLifecycle'
2
+ import type { PriceAdjustment } from '../data/price-adjustments'
3
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
4
+ import { loadQuoteAdjustments } from './priceAdjustments/loadQuoteAdjustments'
5
+ import { resolveManualAdjustments } from './priceAdjustments/resolveManualAdjustments'
6
+ import { withChargeWaterfall } from './priceAdjustments/chargeWaterfall'
7
+ import { withQuotePricingWrite } from './priceAdjustments/quotePricingWrite'
8
+ import { emitQuoteWriteEvents, type QuoteWriteEvents } from './priceAdjustments/quotePricingEvents'
9
+ import { computeWaterfallSummary } from './priceAdjustments/pricingSnapshot'
1
10
  import type { EntityManager } from '@mikro-orm/postgresql'
2
11
  import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
3
12
  import { CustomerEntity } from '@open-mercato/core/modules/customers/data/entities'
@@ -36,6 +45,17 @@ import {
36
45
  ARC_QUOTE_TYPES,
37
46
  getBaseCurrencyCode,
38
47
  } from './types'
48
+ import {
49
+ ARC_PRICE_FROZEN_KEY,
50
+ ARC_REPRICED_KEY,
51
+ arcPricingInputsChanged,
52
+ buildArcMirrorConfiguration,
53
+ isFrozenAmendMirrorLine,
54
+ isArcMirrorLine,
55
+ stripArcMarkers,
56
+ stripQuoteOnlyArcMarkers,
57
+ } from './arcPriceFreeze'
58
+ import { isArcCancelledLine, planArcLineRemoval, planArcLineRestore, removalCancelsItem } from './arcLineRemoval'
39
59
 
40
60
  interface QuotingServiceDeps {
41
61
  em: EntityManager
@@ -169,15 +189,32 @@ export class DefaultCpqQuotingService {
169
189
  // ─── Add Quote Item ────────────────────────────────────────────
170
190
 
171
191
  async addQuoteItem(quoteId: string, item: QuoteItemInput, scope: TenantScope): Promise<QuoteResult> {
172
- const em = this.em
192
+ return this.writeQuote(quoteId, scope, (em, quote) => this.addQuoteItemInTransaction(quoteId, item, scope, em, quote), undefined, undefined, LINE_EVENTS)
193
+ }
194
+
195
+ private async addQuoteItemInTransaction(quoteId: string, item: QuoteItemInput, scope: TenantScope, em: EntityManager, cpqConfig: CpqQuoteConfiguration): Promise<QuoteResult> {
196
+ // ARC markers (`_arc*`) are server-owned — never accept them from callers.
197
+ item = { ...item, configuration: stripArcMarkers(item.configuration) }
173
198
 
174
- const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
175
199
  const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
176
200
 
177
201
  if (TERMINAL_STATUSES.includes(cpqConfig.cpqStatus as CpqStatus)) {
178
202
  throw new QuotingError(409, `Cannot modify quote in '${cpqConfig.cpqStatus}' status`)
179
203
  }
180
204
 
205
+ if (item.parentLineId && cpqConfig.quoteType && cpqConfig.quoteType !== 'new') {
206
+ const parent = await em.findOne(CpqQuoteLineConfiguration, {
207
+ quoteLineId: item.parentLineId,
208
+ quoteConfigurationId: cpqConfig.id,
209
+ organizationId: scope.organizationId,
210
+ tenantId: scope.tenantId,
211
+ deletedAt: null,
212
+ })
213
+ if (parent && isArcCancelledLine(parent, cpqConfig.quoteType)) {
214
+ throw new QuotingError(409, 'Cannot add a component to a subscription item marked for removal')
215
+ }
216
+ }
217
+
181
218
  // Resolve offering -> spec -> product
182
219
  const { offering, spec } = await this.resolveOffering(em, item, scope)
183
220
 
@@ -322,15 +359,23 @@ export class DefaultCpqQuotingService {
322
359
 
323
360
  // ─── Update Quote Item ─────────────────────────────────────────
324
361
 
325
- async updateQuoteItem(
362
+ async updateQuoteItem(quoteId: string,
363
+ lineId: string,
364
+ update: { configuration: Record<string, unknown>; quantity?: number; usageEstimates?: Array<{ chargeCode: string; estimatedQuantity: number }>; startDate?: string; termMonths?: number; endDate?: string },
365
+ scope: TenantScope): Promise<QuoteResult> {
366
+ return this.writeQuote(quoteId, scope, (em, quote) => this.updateQuoteItemInTransaction(quoteId, lineId, update, scope, em, quote), undefined, undefined, LINE_EVENTS)
367
+ }
368
+
369
+ private async updateQuoteItemInTransaction(
326
370
  quoteId: string,
327
371
  lineId: string,
328
372
  update: { configuration: Record<string, unknown>; quantity?: number; usageEstimates?: Array<{ chargeCode: string; estimatedQuantity: number }>; startDate?: string; termMonths?: number; endDate?: string },
329
- scope: TenantScope,
330
- ): Promise<QuoteResult> {
331
- const em = this.em
373
+ scope: TenantScope, em: EntityManager, cpqConfig: CpqQuoteConfiguration): Promise<QuoteResult> {
374
+ // ARC markers (`_arc*`) are server-owned: the quote page echoes the line's
375
+ // stored configuration (markers included) back, so drop them here and
376
+ // re-apply the server's own markers below.
377
+ update = { ...update, configuration: stripArcMarkers(update.configuration) }
332
378
 
333
- const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
334
379
  const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
335
380
 
336
381
  if (TERMINAL_STATUSES.includes(cpqConfig.cpqStatus as CpqStatus)) {
@@ -345,6 +390,9 @@ export class DefaultCpqQuotingService {
345
390
  deletedAt: null,
346
391
  })
347
392
  if (!lineConfig) throw new QuotingError(404, 'Quote line not found')
393
+ if (isArcCancelledLine(lineConfig, cpqConfig.quoteType)) {
394
+ throw new QuotingError(409, 'This subscription item is marked for removal — restore it before changing it')
395
+ }
348
396
 
349
397
  const quoteLine = await em.findOne(SalesQuoteLine, {
350
398
  id: lineId,
@@ -386,10 +434,11 @@ export class DefaultCpqQuotingService {
386
434
  validationErrors = validation.errors
387
435
  }
388
436
 
389
- // Preserve XD-250 ARC markers across edits — the UI doesn't echo them
390
- // back in `update.configuration`, so we'd lose `_arcMirroredFromItemId` /
391
- // `_arcMirroredName` (and downstream "Mirrored from existing subscription
392
- // item" rendering, plus the recalc / validate paths that key off them).
437
+ // Preserve XD-250 ARC markers across edits — client-sent markers were
438
+ // stripped above, so carry `_arcMirroredFromItemId` / `_arcMirroredName`
439
+ // over from the stored configuration (downstream "Mirrored from existing
440
+ // subscription item" rendering plus the recalc / validate paths key off
441
+ // them).
393
442
  const existingCfg = lineConfig.configuration as Record<string, unknown> | null
394
443
  const arcMarkers: Record<string, unknown> = {}
395
444
  if (existingCfg?._arcMirroredFromItemId !== undefined) {
@@ -398,6 +447,26 @@ export class DefaultCpqQuotingService {
398
447
  if (existingCfg?._arcMirroredName !== undefined) {
399
448
  arcMarkers._arcMirroredName = existingCfg._arcMirroredName
400
449
  }
450
+ // A frozen Amend mirror line keeps the subscription's stored price until
451
+ // a pricing input changes; then it is unfrozen, marked re-priced and
452
+ // priced as usual.
453
+ const wasFrozen = isFrozenAmendMirrorLine(existingCfg, cpqConfig.quoteType)
454
+ const pricingInputsChanged =
455
+ wasFrozen &&
456
+ arcPricingInputsChanged(
457
+ {
458
+ configuration: existingCfg,
459
+ quantity: lineConfig.quantity,
460
+ termMonths: lineConfig.termMonths,
461
+ startDate: lineConfig.startDate,
462
+ endDate: lineConfig.endDate,
463
+ usageEstimates: lineConfig.usageEstimates,
464
+ },
465
+ update,
466
+ )
467
+ const staysFrozen = wasFrozen && !pricingInputsChanged
468
+ if (staysFrozen) arcMarkers[ARC_PRICE_FROZEN_KEY] = true
469
+ if (pricingInputsChanged || existingCfg?.[ARC_REPRICED_KEY] === true) arcMarkers[ARC_REPRICED_KEY] = true
401
470
  update.configuration = { ...update.configuration, ...arcMarkers }
402
471
  mergedConfig._arcMirroredFromItemId = arcMarkers._arcMirroredFromItemId
403
472
  mergedConfig._arcMirroredName = arcMarkers._arcMirroredName
@@ -409,7 +478,7 @@ export class DefaultCpqQuotingService {
409
478
  // to "incomplete" with mrcTotal=0.
410
479
  const isArcMirrored = !!(arcMarkers._arcMirroredFromItemId)
411
480
  let charges: ResolvedCharge[]
412
- if (lineConfig.offeringId || lineConfig.productId) {
481
+ if ((lineConfig.offeringId || lineConfig.productId) && !staysFrozen) {
413
482
  charges = await this.pricingService.resolveProductCharges({
414
483
  offeringId: lineConfig.offeringId ?? undefined,
415
484
  productId: lineConfig.productId ?? undefined,
@@ -467,9 +536,11 @@ export class DefaultCpqQuotingService {
467
536
  // ─── Remove Quote Item ─────────────────────────────────────────
468
537
 
469
538
  async removeQuoteItem(quoteId: string, lineId: string, scope: TenantScope): Promise<QuoteResult> {
470
- const em = this.em
539
+ return this.writeQuote(quoteId, scope, (em, quote) => this.removeQuoteItemInTransaction(quoteId, lineId, scope, em, quote), undefined, undefined, LINE_EVENTS)
540
+ }
541
+
542
+ private async removeQuoteItemInTransaction(quoteId: string, lineId: string, scope: TenantScope, em: EntityManager, cpqConfig: CpqQuoteConfiguration): Promise<QuoteResult> {
471
543
 
472
- const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
473
544
  const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
474
545
 
475
546
  if (TERMINAL_STATUSES.includes(cpqConfig.cpqStatus as CpqStatus)) {
@@ -486,37 +557,92 @@ export class DefaultCpqQuotingService {
486
557
  })
487
558
  if (!lineConfig) throw new QuotingError(404, 'Quote line not found')
488
559
 
489
- const now = new Date()
490
- lineConfig.deletedAt = now
560
+ if (removalCancelsItem(lineConfig, cpqConfig.quoteType)) {
561
+ // Amend / Renew: a line mirroring a live subscription item becomes a
562
+ // `cancel` line so activation terminates the item (see arcLineRemoval.ts).
563
+ if (lineConfig.action === 'cancel') {
564
+ throw new QuotingError(409, 'This subscription item is already marked for removal')
565
+ }
566
+ const plan = planArcLineRemoval(lineConfig, await loadLines(em, cpqConfig.id, scope), cpqConfig.quoteType)
567
+ for (const root of plan.removeRoots) await removeQuoteLineTree(em, cpqConfig.id, root, scope)
568
+ await withAtomicFlush(em, [() => {
569
+ for (const line of plan.cancel) {
570
+ line.action = 'cancel'
571
+ line.charges = []
572
+ line.nrcTotal = '0'
573
+ line.mrcTotal = '0'
574
+ line.isConfigured = true
575
+ line.validationErrors = null
576
+ }
577
+ }])
578
+ } else {
579
+ await removeQuoteLineTree(em, cpqConfig.id, lineConfig, scope)
580
+ }
491
581
 
492
- // Soft-delete children
493
- const children = await em.find(CpqQuoteLineConfiguration, {
494
- parentLineId: lineId,
495
- quoteConfigurationId: cpqConfig.id,
496
- organizationId: scope.organizationId,
497
- tenantId: scope.tenantId,
498
- deletedAt: null,
499
- })
500
- for (const child of children) {
501
- child.deletedAt = now
502
- // Also soft-delete the SalesQuoteLine
503
- const childSalesLine = await em.findOne(SalesQuoteLine, {
504
- id: child.quoteLineId,
505
- organizationId: scope.organizationId,
506
- tenantId: scope.tenantId,
507
- })
508
- if (childSalesLine) childSalesLine.deletedAt = now
582
+ return this.recalculateInternal(em, cpqConfig, salesQuote, scope, true)
583
+ }
584
+
585
+ // ─── Restore a removed subscription item (Amend / Renew) ───────
586
+
587
+ async restoreQuoteItem(quoteId: string, lineId: string, scope: TenantScope): Promise<QuoteResult> {
588
+ return this.writeQuote(quoteId, scope, (em, quote) => this.restoreQuoteItemInTransaction(lineId, scope, em, quote), undefined, undefined, LINE_EVENTS)
589
+ }
590
+
591
+ private async restoreQuoteItemInTransaction(lineId: string, scope: TenantScope, em: EntityManager, cpqConfig: CpqQuoteConfiguration): Promise<QuoteResult> {
592
+ const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
593
+
594
+ if (TERMINAL_STATUSES.includes(cpqConfig.cpqStatus as CpqStatus)) {
595
+ throw new QuotingError(409, `Cannot modify quote in '${cpqConfig.cpqStatus}' status`)
509
596
  }
510
597
 
511
- // Soft-delete the SalesQuoteLine
512
- const salesLine = await em.findOne(SalesQuoteLine, {
513
- id: lineId,
598
+ const lines = await loadLines(em, cpqConfig.id, scope)
599
+ const lineConfig = lines.find((line) => line.quoteLineId === lineId)
600
+ if (!lineConfig) throw new QuotingError(404, 'Quote line not found')
601
+ if (!isArcCancelledLine(lineConfig, cpqConfig.quoteType) || !isArcMirrorLine(lineConfig.configuration, cpqConfig.quoteType)) {
602
+ throw new QuotingError(409, 'Only subscription items marked for removal can be restored')
603
+ }
604
+
605
+ const plan = planArcLineRestore(lineConfig, lines, cpqConfig.quoteType)
606
+ if (!plan.ok) throw new QuotingError(409, 'Restore the parent item first')
607
+
608
+ const itemIdOf = (line: CpqQuoteLineConfiguration) =>
609
+ line.sourceSubscriptionItemId ?? ((line.configuration as Record<string, unknown> | null)?._arcMirroredFromItemId as string | undefined) ?? null
610
+ const itemIds = plan.restore.map(itemIdOf).filter((id): id is string => !!id)
611
+ const items = itemIds.length
612
+ ? await em.find(CpqInventorySubscriptionItem, { id: { $in: itemIds }, organizationId: scope.organizationId, tenantId: scope.tenantId, deletedAt: null })
613
+ : []
614
+ const itemById = new Map(items.map((item) => [item.id, item]))
615
+ const salesLines = await em.find(SalesQuoteLine, {
616
+ id: { $in: plan.restore.map((line) => line.quoteLineId) },
514
617
  organizationId: scope.organizationId,
515
618
  tenantId: scope.tenantId,
619
+ deletedAt: null,
516
620
  })
517
- if (salesLine) salesLine.deletedAt = now
621
+ const salesLineById = new Map(salesLines.map((line) => [line.id, line]))
518
622
 
519
- await em.flush()
623
+ const restores = plan.restore.map((line) => {
624
+ const itemId = itemIdOf(line)
625
+ const item = itemId ? itemById.get(itemId) : undefined
626
+ if (!item || ['terminated', 'expired', 'superseded'].includes(item.status)) {
627
+ throw new QuotingError(409, 'The subscription item is no longer active and cannot be restored')
628
+ }
629
+ return { line, item }
630
+ })
631
+ const arcType = cpqConfig.quoteType as 'amend' | 'renew'
632
+ await withAtomicFlush(em, [() => {
633
+ for (const { line, item } of restores) {
634
+ line.action = 'modify'
635
+ line.configuration = buildArcMirrorConfiguration(item.configuration, item, arcType)
636
+ line.charges = pickArcMirrorCharges(item, cpqConfig.currencyCode)
637
+ line.quantity = item.quantity
638
+ line.nrcTotal = String(item.nrcAmount ?? 0)
639
+ line.mrcTotal = String(item.mrcAmount)
640
+ line.isConfigured = true
641
+ line.validationErrors = null
642
+ const salesLine = salesLineById.get(line.quoteLineId)
643
+ if (salesLine) salesLine.quantity = String(item.quantity)
644
+ }
645
+ }])
520
646
 
521
647
  return this.recalculateInternal(em, cpqConfig, salesQuote, scope, true)
522
648
  }
@@ -542,20 +668,46 @@ export class DefaultCpqQuotingService {
542
668
  // ─── Recalculate ───────────────────────────────────────────────
543
669
 
544
670
  async recalculate(quoteId: string, options: { save: boolean }, scope: TenantScope): Promise<QuoteResult> {
545
- const em = options.save ? this.em.fork() : this.em
671
+ if (options.save) {
672
+ return this.writeQuote(quoteId, scope, (em, quote) => this.recalculateFromConfig(em, quote, scope, true), undefined,
673
+ (quote) => {
674
+ if (TERMINAL_STATUSES.includes(quote.cpqStatus as CpqStatus)) throw new QuotingError(409, `Cannot recalculate quote in '${quote.cpqStatus}' status`)
675
+ })
676
+ }
677
+ const cpqConfig = await this.loadCpqConfig(this.em, quoteId, scope)
678
+ return this.recalculateFromConfig(this.em, { ...cpqConfig } as CpqQuoteConfiguration, scope, false)
679
+ }
680
+
681
+ private async recalculateFromConfig(em: EntityManager, cpqConfig: CpqQuoteConfiguration, scope: TenantScope, save: boolean): Promise<QuoteResult> {
682
+
683
+ // A terminal quote's stored prices are final (accepted quotes are the
684
+ // basis for order conversion) — a saved recalculation must not move them.
685
+ if (save && TERMINAL_STATUSES.includes(cpqConfig.cpqStatus as CpqStatus)) {
686
+ throw new QuotingError(409, `Cannot recalculate quote in '${cpqConfig.cpqStatus}' status`)
687
+ }
546
688
 
547
- const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
548
689
  const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
549
690
 
550
- return this.recalculateInternal(em, cpqConfig, salesQuote, scope, options.save)
691
+ return this.recalculateInternal(em, cpqConfig, salesQuote, scope, save)
551
692
  }
552
693
 
553
694
  // ─── Clone Quote ───────────────────────────────────────────────
554
695
 
555
696
  async cloneQuote(quoteId: string, scope: TenantScope): Promise<QuoteResult> {
556
- const em = this.em
697
+ const created: { quote?: CpqQuoteConfiguration } = {}
698
+ const result = await this.writeQuote(quoteId, scope,
699
+ (em, quote) => this.cloneQuoteInTransaction(quoteId, scope, em, quote, created),
700
+ undefined, undefined, { pricingCause: 'clone' })
701
+ // The copy is a new aggregate: its first committed snapshot is a pricing signal.
702
+ if (created.quote) await emitQuoteWriteEvents(null, created.quote, { pricingCause: 'clone' })
703
+ return result
704
+ }
705
+
706
+ private async cloneQuoteInTransaction(
707
+ quoteId: string, scope: TenantScope, em: EntityManager, original: CpqQuoteConfiguration,
708
+ created: { quote?: CpqQuoteConfiguration } = {},
709
+ ): Promise<QuoteResult> {
557
710
 
558
- const original = await this.loadCpqConfig(em, quoteId, scope)
559
711
  const originalQuote = await this.loadSalesQuote(em, original.quoteId, scope)
560
712
 
561
713
  // Generate new quote number
@@ -589,11 +741,13 @@ export class DefaultCpqQuotingService {
589
741
 
590
742
  // Create new CpqQuoteConfiguration
591
743
  const newCpqConfig = em.create(CpqQuoteConfiguration, {
744
+ id: crypto.randomUUID(),
592
745
  organizationId: scope.organizationId,
593
746
  tenantId: scope.tenantId,
594
747
  quoteId: newSalesQuoteId,
595
748
  customerId: original.customerId,
596
749
  cpqStatus: 'new',
750
+ pricingRevision: 1,
597
751
  version: original.version + 1,
598
752
  parentQuoteId: original.id,
599
753
  currencyCode: original.currencyCode,
@@ -602,6 +756,8 @@ export class DefaultCpqQuotingService {
602
756
  pricingSummary: emptyPricingSummary(original.currencyCode),
603
757
  })
604
758
  em.persist(newCpqConfig)
759
+ created.quote = newCpqConfig
760
+ await em.flush()
605
761
 
606
762
  // Copy all lines
607
763
  const originalLines = await em.find(CpqQuoteLineConfiguration, {
@@ -663,7 +819,8 @@ export class DefaultCpqQuotingService {
663
819
  startDate: origLine.startDate,
664
820
  termMonths: origLine.termMonths,
665
821
  endDate: origLine.endDate,
666
- configuration: { ...origLine.configuration },
822
+ // A clone is a new quote: quote-only ARC pricing markers don't apply.
823
+ configuration: { ...stripQuoteOnlyArcMarkers(origLine.configuration) },
667
824
  quantity: origLine.quantity,
668
825
  charges: origLine.charges ? [...origLine.charges] : null,
669
826
  nrcTotal: origLine.nrcTotal,
@@ -695,15 +852,21 @@ export class DefaultCpqQuotingService {
695
852
  await em.flush()
696
853
 
697
854
  // Recalculate the new quote at current rates
855
+ await cloneQuoteAdjustments(em, original.id, newCpqConfig, lineIdMap, scope)
856
+ await em.flush()
857
+
698
858
  return this.recalculateInternal(em, newCpqConfig, newSalesQuote, scope, true)
699
859
  }
700
860
 
701
861
  // ─── Transition Status ─────────────────────────────────────────
702
862
 
703
- async transitionStatus(quoteId: string, targetStatus: string, scope: TenantScope): Promise<QuoteResult> {
704
- const em = this.em
863
+ async transitionStatus(quoteId: string, targetStatus: string, scope: TenantScope, actorUserId: string | null = null): Promise<QuoteResult> {
864
+ return this.writeQuote(quoteId, scope, (em, quote) => this.transitionStatusInTransaction(quoteId, targetStatus, scope, em, quote),
865
+ undefined, undefined, { pricingCause: 'recalculate', statusCause: 'manual', actorUserId })
866
+ }
867
+
868
+ private async transitionStatusInTransaction(quoteId: string, targetStatus: string, scope: TenantScope, em: EntityManager, cpqConfig: CpqQuoteConfiguration): Promise<QuoteResult> {
705
869
 
706
- const cpqConfig = await this.loadCpqConfig(em, quoteId, scope)
707
870
  const salesQuote = await this.loadSalesQuote(em, cpqConfig.quoteId, scope)
708
871
 
709
872
  const currentStatus = cpqConfig.cpqStatus as CpqStatus
@@ -725,7 +888,7 @@ export class DefaultCpqQuotingService {
725
888
  targetStatus === 'with_customer'
726
889
  const quoteType = cpqConfig.quoteType ?? 'new'
727
890
  if (isSubmitForApproval && quoteType !== 'new') {
728
- const validation = await this.validateArcQuote(quoteId, scope)
891
+ const validation = await this.validateArcQuote(quoteId, scope, em)
729
892
  if (!validation.ok) {
730
893
  throw new QuotingError(
731
894
  422,
@@ -747,6 +910,28 @@ export class DefaultCpqQuotingService {
747
910
  return this.buildQuoteResult(em, cpqConfig, salesQuote, lines)
748
911
  }
749
912
 
913
+ private async writeQuote(
914
+ quoteId: string,
915
+ scope: TenantScope,
916
+ execute: (em: EntityManager, quote: CpqQuoteConfiguration) => Promise<QuoteResult>,
917
+ expectedRevision?: number,
918
+ validate?: (quote: CpqQuoteConfiguration) => void,
919
+ events: QuoteWriteEvents = { pricingCause: 'recalculate' },
920
+ ): Promise<QuoteResult> {
921
+ return withQuotePricingWrite(this.em, quoteId, scope, execute,
922
+ (status, message) => new QuotingError(status, message), expectedRevision, validate, events)
923
+ }
924
+
925
+ /** Called by adjustment commands that already own the quote lock/revision. */
926
+ async recalculateLockedQuote(
927
+ em: EntityManager, quote: CpqQuoteConfiguration, scope: TenantScope,
928
+ save = true, adjustments?: PriceAdjustment[],
929
+ ): Promise<QuoteResult> {
930
+ const salesQuote = await this.loadSalesQuote(em, quote.quoteId, scope)
931
+ const config = save ? quote : { ...quote } as CpqQuoteConfiguration
932
+ return this.recalculateInternal(em, config, salesQuote, scope, save, adjustments)
933
+ }
934
+
750
935
  // ─── Internal: Recalculate ─────────────────────────────────────
751
936
 
752
937
  private async recalculateInternal(
@@ -755,6 +940,7 @@ export class DefaultCpqQuotingService {
755
940
  salesQuote: SalesQuote,
756
941
  scope: TenantScope,
757
942
  save: boolean,
943
+ draftAdjustments?: PriceAdjustment[],
758
944
  ): Promise<QuoteResult> {
759
945
  const lineConfigs = await em.find(CpqQuoteLineConfiguration, {
760
946
  quoteConfigurationId: cpqConfig.id,
@@ -800,7 +986,7 @@ export class DefaultCpqQuotingService {
800
986
  }
801
987
 
802
988
  let validationErrors: ValidationError[] = []
803
- if (lineConfig.offeringId) {
989
+ if (lineConfig.offeringId && !isArcCancelledLine(lineConfig, cpqConfig.quoteType)) {
804
990
  const validation = await this.validationService.validateOfferingConfiguration({
805
991
  offeringId: lineConfig.offeringId,
806
992
  configuration: lineConfig.configuration,
@@ -817,8 +1003,16 @@ export class DefaultCpqQuotingService {
817
1003
  // line to "incomplete".
818
1004
  const cfgRecord = lineConfig.configuration as Record<string, unknown> | null
819
1005
  const isArcMirrored = !!(cfgRecord?._arcMirroredFromItemId)
1006
+ // Frozen Amend mirror lines keep the subscription item's stored charges
1007
+ // (see arcPriceFreeze.ts) — re-pricing them would silently move the
1008
+ // contracted price of items the amendment does not touch.
1009
+ const isFrozen = isFrozenAmendMirrorLine(cfgRecord, cpqConfig.quoteType)
820
1010
  let charges: ResolvedCharge[]
821
- if (lineConfig.offeringId || lineConfig.productId) {
1011
+ if (isArcCancelledLine(lineConfig, cpqConfig.quoteType)) {
1012
+ // Item marked for removal: it leaves the subscription, so it adds
1013
+ // nothing to the quote (its current price is shown via `arcSource`).
1014
+ charges = []
1015
+ } else if ((lineConfig.offeringId || lineConfig.productId) && !isFrozen) {
822
1016
  charges = await this.pricingService.resolveProductCharges({
823
1017
  offeringId: lineConfig.offeringId ?? undefined,
824
1018
  productId: lineConfig.productId ?? undefined,
@@ -844,37 +1038,72 @@ export class DefaultCpqQuotingService {
844
1038
 
845
1039
  computedLines.push({ lineConfig, charges, nrcTotal, mrcTotal, usageEstimates, usageTotalEstimated, validationErrors, isConfigured })
846
1040
 
1041
+ }
1042
+ const adjustments = draftAdjustments ?? await loadQuoteAdjustments(em, cpqConfig.id, scope)
1043
+ const adjusted = resolveManualAdjustments(computedLines.map(({ lineConfig, charges }) => ({
1044
+ lineId: lineConfig.quoteLineId, parentLineId: lineConfig.parentLineId ?? null,
1045
+ enginePriced: !!(lineConfig.offeringId || lineConfig.productId),
1046
+ mirrored: isArcMirrorLine(lineConfig.configuration, cpqConfig.quoteType), charges,
1047
+ })), adjustments, cpqConfig.currencyCode)
1048
+ const adjustedById = new Map(adjusted.lines.map((line) => [line.lineId, line]))
1049
+ const computedIds = new Set(computedLines.map((line) => line.lineConfig.quoteLineId))
1050
+ for (const computed of computedLines) {
1051
+ const { lineConfig } = computed
1052
+ // No adjustments: preserve legacy/frozen snapshots byte-for-byte on disk.
1053
+ if (adjustments.length) computed.charges = adjustedById.get(lineConfig.quoteLineId)!.charges
1054
+ const adjustmentErrors = adjusted.errors.filter((error) => error.quoteLineId === lineConfig.quoteLineId)
1055
+ if (adjustmentErrors.length) {
1056
+ computed.isConfigured = false
1057
+ allValid = false
1058
+ const errors = adjustmentErrors.map((error) => ({ message: error.code, attribute: error.chargeCode, lineId: error.quoteLineId, adjustmentId: error.adjustmentId }))
1059
+ computed.validationErrors.push(...errors)
1060
+ allErrors.push(...errors)
1061
+ }
1062
+ Object.assign(computed, computeLineTotals(computed.charges, lineConfig.quantity,
1063
+ lineConfig.usageEstimates?.map((entry) => ({ chargeCode: entry.chargeCode, estimatedQuantity: entry.estimatedQuantity }))))
1064
+ const { charges, nrcTotal, mrcTotal, usageEstimates, usageTotalEstimated, validationErrors, isConfigured } = computed
847
1065
  if (save) {
848
- em.assign(lineConfig, {
849
- charges: charges as unknown as Array<Record<string, unknown>>,
850
- nrcTotal: String(nrcTotal),
851
- mrcTotal: String(mrcTotal),
852
- usageEstimates: usageEstimates ?? null,
853
- usageTotalEstimated: usageTotalEstimated != null ? String(usageTotalEstimated) : null,
854
- validationErrors: validationErrors.length > 0 ? validationErrors as unknown as Array<Record<string, unknown>> : null,
855
- isConfigured,
856
- })
857
-
858
- const salesLine = await em.findOne(SalesQuoteLine, {
859
- id: lineConfig.quoteLineId,
860
- organizationId: scope.organizationId,
861
- tenantId: scope.tenantId,
862
- })
863
- if (salesLine) {
864
- const perInstanceNrc = charges.filter((c) => c.chargeType === 'nrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
865
- const perInstanceMrc = charges.filter((c) => c.chargeType === 'mrc').reduce((s, c) => s + (c.totalPrice ?? 0), 0)
866
- const unitPriceNet = perInstanceNrc + perInstanceMrc
867
- em.assign(salesLine, {
868
- quantity: String(lineConfig.quantity),
869
- unitPriceNet: String(unitPriceNet),
870
- unitPriceGross: String(unitPriceNet),
871
- totalNetAmount: String(unitPriceNet * lineConfig.quantity),
872
- totalGrossAmount: String(unitPriceNet * lineConfig.quantity),
873
- updatedAt: new Date(),
874
- })
875
- }
1066
+ await withAtomicFlush(em, [
1067
+ () => {
1068
+ em.assign(lineConfig, {
1069
+ charges: charges as unknown as Array<Record<string, unknown>>,
1070
+ nrcTotal: String(nrcTotal),
1071
+ mrcTotal: String(mrcTotal),
1072
+ usageEstimates: usageEstimates ?? null,
1073
+ usageTotalEstimated: usageTotalEstimated != null ? String(usageTotalEstimated) : null,
1074
+ validationErrors: validationErrors.length > 0 ? validationErrors as unknown as Array<Record<string, unknown>> : null,
1075
+ isConfigured,
1076
+ })
1077
+ },
1078
+ async () => {
1079
+ const salesLine = await em.findOne(SalesQuoteLine, {
1080
+ id: lineConfig.quoteLineId,
1081
+ organizationId: scope.organizationId,
1082
+ tenantId: scope.tenantId,
1083
+ })
1084
+ if (salesLine) {
1085
+ const perInstanceNrc = charges.filter((charge) => charge.chargeType === 'nrc').reduce((sum, charge) => sum + (charge.totalPrice ?? 0), 0)
1086
+ const perInstanceMrc = charges.filter((charge) => charge.chargeType === 'mrc').reduce((sum, charge) => sum + (charge.totalPrice ?? 0), 0)
1087
+ const unitPriceNet = perInstanceNrc + perInstanceMrc
1088
+ em.assign(salesLine, {
1089
+ quantity: String(lineConfig.quantity),
1090
+ unitPriceNet: String(unitPriceNet),
1091
+ unitPriceGross: String(unitPriceNet),
1092
+ totalNetAmount: String(unitPriceNet * lineConfig.quantity),
1093
+ totalGrossAmount: String(unitPriceNet * lineConfig.quantity),
1094
+ updatedAt: new Date(),
1095
+ })
1096
+ }
1097
+ },
1098
+ ])
876
1099
  }
877
1100
  }
1101
+ // A missing line target must still invalidate the quote (normally removal
1102
+ // soft-deletes its adjustments in the same aggregate transaction).
1103
+ for (const error of adjusted.errors.filter((entry) => !computedIds.has(entry.quoteLineId))) {
1104
+ allValid = false
1105
+ allErrors.push({ message: error.code, attribute: error.chargeCode, lineId: error.quoteLineId, adjustmentId: error.adjustmentId })
1106
+ }
878
1107
 
879
1108
  const relationshipErrors = await this.validationService.validateRelationships({
880
1109
  lines: lineConfigs.map((l) => ({
@@ -903,6 +1132,10 @@ export class DefaultCpqQuotingService {
903
1132
 
904
1133
  // Build pricing summary from freshly computed data (not from stale entity fields)
905
1134
  const pricingSummary = computePricingSummaryFromComputed(computedLines, cpqConfig.currencyCode)
1135
+ Object.assign(pricingSummary, computeWaterfallSummary(computedLines.map(({ lineConfig, charges }) => ({
1136
+ lineId: lineConfig.quoteLineId, quantity: lineConfig.quantity, termMonths: lineConfig.termMonths,
1137
+ includeInDeal: !isArcMirrorLine(lineConfig.configuration, cpqConfig.quoteType), charges,
1138
+ })), adjustments, cpqConfig.currencyCode))
906
1139
 
907
1140
  const validationResult = { valid: allValid, errors: allErrors }
908
1141
 
@@ -923,7 +1156,9 @@ export class DefaultCpqQuotingService {
923
1156
  await em.flush()
924
1157
  }
925
1158
 
926
- return this.buildQuoteResultFromComputed(em, cpqConfig, salesQuote, computedLines, validationResult, pricingSummary)
1159
+ const result = await this.buildQuoteResultFromComputed(em, cpqConfig, salesQuote, computedLines, validationResult, pricingSummary)
1160
+ result.adjustmentErrors = adjusted.errors
1161
+ return result
927
1162
  }
928
1163
 
929
1164
  // ─── Helpers ───────────────────────────────────────────────────
@@ -1039,7 +1274,7 @@ export class DefaultCpqQuotingService {
1039
1274
  mrcTotal: Number(lc.mrcTotal),
1040
1275
  usageEstimates: lc.usageEstimates ?? null,
1041
1276
  usageTotalEstimated: lc.usageTotalEstimated != null ? Number(lc.usageTotalEstimated) : null,
1042
- charges: (lc.charges ?? []) as unknown as ResolvedCharge[],
1277
+ charges: ((lc.charges ?? []) as unknown as ResolvedCharge[]).map(withChargeWaterfall),
1043
1278
  isConfigured: lc.isConfigured,
1044
1279
  validationErrors: lc.validationErrors as unknown as ValidationError[] ?? null,
1045
1280
  arcSource: pickArcSource(lc, arcSourceMap),
@@ -1049,6 +1284,13 @@ export class DefaultCpqQuotingService {
1049
1284
 
1050
1285
  const finalValidation = validationResult ?? (cpqConfig.validationResult as { valid: boolean; errors: ValidationError[] }) ?? { valid: true, errors: [] }
1051
1286
  const finalPricing = pricingSummary ?? computePricingSummary(lineConfigs, cpqConfig.currencyCode)
1287
+ if (!pricingSummary) {
1288
+ const adjustments = await loadQuoteAdjustments(em, cpqConfig.id, { organizationId: cpqConfig.organizationId, tenantId: cpqConfig.tenantId })
1289
+ Object.assign(finalPricing, computeWaterfallSummary(lines.map((line) => ({
1290
+ lineId: line.lineId, quantity: line.quantity, termMonths: line.termMonths,
1291
+ includeInDeal: !isArcMirrorLine(line.configuration, cpqConfig.quoteType), charges: line.charges,
1292
+ })), adjustments, cpqConfig.currencyCode))
1293
+ }
1052
1294
 
1053
1295
  const { customerName, convertedOrderId } = await resolveQuoteSideData(em, cpqConfig)
1054
1296
 
@@ -1060,6 +1302,7 @@ export class DefaultCpqQuotingService {
1060
1302
  customerName,
1061
1303
  cpqStatus: cpqConfig.cpqStatus,
1062
1304
  version: cpqConfig.version,
1305
+ pricingRevision: cpqConfig.pricingRevision ?? 0,
1063
1306
  parentQuoteId: cpqConfig.parentQuoteId ?? null,
1064
1307
  currencyCode: cpqConfig.currencyCode,
1065
1308
  convertedOrderId,
@@ -1136,7 +1379,7 @@ export class DefaultCpqQuotingService {
1136
1379
  mrcTotal,
1137
1380
  usageEstimates,
1138
1381
  usageTotalEstimated,
1139
- charges,
1382
+ charges: charges.map(withChargeWaterfall),
1140
1383
  isConfigured,
1141
1384
  validationErrors: validationErrors.length > 0 ? validationErrors : null,
1142
1385
  arcSource: pickArcSource(lc, arcSourceMap),
@@ -1154,6 +1397,7 @@ export class DefaultCpqQuotingService {
1154
1397
  customerName,
1155
1398
  cpqStatus: cpqConfig.cpqStatus,
1156
1399
  version: cpqConfig.version,
1400
+ pricingRevision: cpqConfig.pricingRevision ?? 0,
1157
1401
  parentQuoteId: cpqConfig.parentQuoteId ?? null,
1158
1402
  currencyCode: cpqConfig.currencyCode,
1159
1403
  convertedOrderId,
@@ -1272,6 +1516,7 @@ function aggregateAdjustmentTotals(allCharges: ResolvedCharge[]): { discountTota
1272
1516
  for (const charge of allCharges) {
1273
1517
  if (!charge.adjustments) continue
1274
1518
  for (const adj of charge.adjustments) {
1519
+ if (adj.origin === 'manual') continue
1275
1520
  if (adj.delta < 0) {
1276
1521
  discountTotal += Math.abs(adj.delta) * (charge.quantity ?? 1)
1277
1522
  } else if (adj.delta > 0) {
@@ -1373,8 +1618,13 @@ function computePricingSummaryFromComputed(
1373
1618
  }
1374
1619
  }
1375
1620
 
1621
+ const LINE_EVENTS: QuoteWriteEvents = { pricingCause: 'line' }
1622
+
1376
1623
  function emptyPricingSummary(currencyCode: string): Record<string, unknown> {
1377
1624
  return {
1625
+ // A new quote starts with a real baseline hash, so its first priced line is
1626
+ // a pricing change; only pre-feature quotes have no stored hash.
1627
+ snapshotHash: computeWaterfallSummary([], [], currencyCode).snapshotHash,
1378
1628
  nrcTotal: 0,
1379
1629
  mrcTotal: 0,
1380
1630
  usageCharges: [],
@@ -1558,7 +1808,7 @@ declare module './cpqQuotingService' {
1558
1808
  ): Promise<TargetSubscriptionView>
1559
1809
  setCancelMeta(quoteId: string, meta: CancelMetaInput, scope: TenantScope): Promise<QuoteResult>
1560
1810
  setMergeMeta(quoteId: string, meta: MergeMetaInput, scope: TenantScope): Promise<QuoteResult>
1561
- validateArcQuote(quoteId: string, scope: TenantScope): Promise<ArcQuoteValidationResult>
1811
+ validateArcQuote(quoteId: string, scope: TenantScope, transactionEm?: EntityManager): Promise<ArcQuoteValidationResult>
1562
1812
  }
1563
1813
  }
1564
1814
 
@@ -1922,50 +2172,51 @@ DefaultCpqQuotingService.prototype.detachTargetSubscription = async function (
1922
2172
  scope: TenantScope,
1923
2173
  ): Promise<{ ok: true }> {
1924
2174
  const em = (this as unknown as { em: EntityManager }).em
1925
- const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
1926
- ensureEditable(cpqConfig)
1927
-
1928
- const target = await em.findOne(CpqQuoteTargetSubscription, {
1929
- id: targetId,
1930
- quoteId: cpqConfig.id,
1931
- organizationId: scope.organizationId,
1932
- tenantId: scope.tenantId,
1933
- deletedAt: null,
1934
- })
1935
- if (!target) throw new QuotingError(404, 'Target subscription not found')
1936
-
1937
- // Drop any quote lines that pointed at this target.
1938
- const lines = await em.find(CpqQuoteLineConfiguration, {
1939
- quoteConfigurationId: cpqConfig.id,
1940
- targetSubscriptionId: target.subscriptionId,
1941
- organizationId: scope.organizationId,
1942
- tenantId: scope.tenantId,
1943
- deletedAt: null,
1944
- })
1945
- const now = new Date()
1946
- for (const line of lines) {
1947
- line.deletedAt = now
1948
- const salesLine = await em.findOne(SalesQuoteLine, { id: line.quoteLineId })
1949
- if (salesLine) salesLine.deletedAt = now
1950
- }
1951
-
1952
- target.deletedAt = now
1953
-
1954
- // Auto-revert merge mode if only one renew target remains.
1955
- if (target.quoteType === 'renew') {
1956
- const remaining = await em.find(CpqQuoteTargetSubscription, {
2175
+ // Detaching removes priced lines (rep-added lines may carry negotiated
2176
+ // prices), so it takes the same aggregate lock and revision bump as any
2177
+ // other pricing write and re-prices the remaining lines.
2178
+ await withQuotePricingWrite(em, quoteId, scope, async (tx, cpqConfig) => {
2179
+ const target = await tx.findOne(CpqQuoteTargetSubscription, {
2180
+ id: targetId,
1957
2181
  quoteId: cpqConfig.id,
1958
2182
  organizationId: scope.organizationId,
1959
2183
  tenantId: scope.tenantId,
1960
2184
  deletedAt: null,
1961
- id: { $ne: target.id },
1962
2185
  })
1963
- if (remaining.length === 1) {
1964
- remaining[0].mergeAction = 'standalone'
2186
+ if (!target) throw new QuotingError(404, 'Target subscription not found')
2187
+
2188
+ // Drop quote lines that pointed at this target, with their bundle
2189
+ // children, sales lines and price adjustments.
2190
+ const lines = await tx.find(CpqQuoteLineConfiguration, {
2191
+ quoteConfigurationId: cpqConfig.id,
2192
+ targetSubscriptionId: target.subscriptionId,
2193
+ organizationId: scope.organizationId,
2194
+ tenantId: scope.tenantId,
2195
+ deletedAt: null,
2196
+ })
2197
+ for (const line of lines) {
2198
+ if (line.deletedAt) continue
2199
+ await removeQuoteLineTree(tx, cpqConfig.id, line, scope)
1965
2200
  }
1966
- }
1967
2201
 
1968
- await em.flush()
2202
+ // Read the remaining renew targets before mutating, so no query runs
2203
+ // between a scalar change and its flush.
2204
+ const remaining = target.quoteType === 'renew'
2205
+ ? await tx.find(CpqQuoteTargetSubscription, {
2206
+ quoteId: cpqConfig.id,
2207
+ organizationId: scope.organizationId,
2208
+ tenantId: scope.tenantId,
2209
+ deletedAt: null,
2210
+ id: { $ne: target.id },
2211
+ })
2212
+ : []
2213
+ await withAtomicFlush(tx, [() => {
2214
+ target.deletedAt = new Date()
2215
+ // Auto-revert merge mode if only one renew target remains.
2216
+ if (remaining.length === 1) remaining[0].mergeAction = 'standalone'
2217
+ }])
2218
+ await this.recalculateLockedQuote(tx, cpqConfig, scope, true)
2219
+ }, (status, message) => new QuotingError(status, message), undefined, ensureEditable, { pricingCause: 'line' })
1969
2220
  return { ok: true }
1970
2221
  }
1971
2222
 
@@ -2062,8 +2313,9 @@ DefaultCpqQuotingService.prototype.validateArcQuote = async function (
2062
2313
  this: DefaultCpqQuotingService,
2063
2314
  quoteId: string,
2064
2315
  scope: TenantScope,
2316
+ transactionEm?: EntityManager,
2065
2317
  ): Promise<ArcQuoteValidationResult> {
2066
- const em = (this as unknown as { em: EntityManager }).em
2318
+ const em = transactionEm ?? (this as unknown as { em: EntityManager }).em
2067
2319
  const cpqConfig = await loadCpqConfigStrict(em, quoteId, scope)
2068
2320
  const errors: string[] = []
2069
2321
 
@@ -2366,6 +2618,7 @@ async function mapTargetView(
2366
2618
 
2367
2619
  function emptyArcPricingSummary(currencyCode: string): Record<string, unknown> {
2368
2620
  return {
2621
+ snapshotHash: computeWaterfallSummary([], [], currencyCode).snapshotHash,
2369
2622
  nrcTotal: 0,
2370
2623
  mrcTotal: 0,
2371
2624
  usageCharges: [],
@@ -2536,11 +2789,7 @@ async function mirrorTargetSubscriptionItems(
2536
2789
  startDate: null,
2537
2790
  termMonths: null,
2538
2791
  endDate: null,
2539
- configuration: {
2540
- ...item.configuration,
2541
- _arcMirroredFromItemId: item.id,
2542
- _arcMirroredName: item.name,
2543
- },
2792
+ configuration: buildArcMirrorConfiguration(item.configuration, item, quoteType),
2544
2793
  quantity: item.quantity,
2545
2794
  charges: pickArcMirrorCharges(item, sub.currencyCode),
2546
2795
  nrcTotal: String(item.nrcAmount ?? 0),