@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,5 +1,11 @@
1
1
  import { DefaultCpqQuotingService } from '../cpqQuotingService'
2
2
  import { createMockEm, TEST_SCOPE as SCOPE } from './_helpers/mockEm'
3
+ import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
4
+ import {
5
+ CpqProductOffering,
6
+ CpqQuoteConfiguration,
7
+ CpqQuoteLineConfiguration,
8
+ } from '../../data/entities'
3
9
 
4
10
  /**
5
11
  * XD-250 ARC unit tests for cpqQuotingService — focused on the
@@ -141,3 +147,234 @@ describe('transitionStatus → validateArcQuote hook', () => {
141
147
  expect(em.findOne).toHaveBeenCalledTimes(4)
142
148
  })
143
149
  })
150
+
151
+ // ─── Amend mirror-line pricing on recalculation ──────────────────
152
+
153
+
154
+ function makePricedService() {
155
+ const em = Object.assign(createMockEm(), {
156
+ assign: jest.fn((entity: Record<string, unknown>, updates: Record<string, unknown>) =>
157
+ Object.assign(entity, updates),
158
+ ),
159
+ fork: jest.fn(),
160
+ })
161
+ em.fork.mockReturnValue(em)
162
+ const pricingService = { resolveProductCharges: jest.fn() }
163
+ const validationService = {
164
+ validateOfferingConfiguration: jest.fn().mockResolvedValue({ valid: true, errors: [] }),
165
+ validateRelationships: jest.fn().mockResolvedValue([]),
166
+ }
167
+ const service = new DefaultCpqQuotingService({
168
+ em: em as never,
169
+ cpqPricingService: pricingService as never,
170
+ cpqValidationService: validationService as never,
171
+ cpqProductService: {} as never,
172
+ salesDocumentNumberGenerator: {} as never,
173
+ })
174
+ return { em, pricingService, service }
175
+ }
176
+
177
+ function mrcCharge(price: number) {
178
+ return {
179
+ chargeCode: 'monthly_port_fee',
180
+ chargeName: 'Monthly Port Fee',
181
+ chargeType: 'mrc',
182
+ pricingMethod: 'flat',
183
+ unitPrice: price,
184
+ quantity: 1,
185
+ totalPrice: price,
186
+ currencyCode: 'USD',
187
+ adjustments: [],
188
+ }
189
+ }
190
+
191
+ function amendFixture(configuration: Record<string, unknown>) {
192
+ const cpqConfig = { ...ARC_QUOTE_BASE, quoteType: 'amend', organizationId: SCOPE.organizationId, tenantId: SCOPE.tenantId }
193
+ const lineConfig = {
194
+ id: 'lc-1',
195
+ quoteLineId: 'ql-1',
196
+ quoteConfigurationId: 'cpq-1',
197
+ offeringId: 'off-1',
198
+ productId: 'prod-1',
199
+ specId: null,
200
+ action: 'modify',
201
+ parentLineId: null,
202
+ startDate: null,
203
+ termMonths: null,
204
+ endDate: null,
205
+ configuration,
206
+ quantity: 1,
207
+ charges: [mrcCharge(100)],
208
+ nrcTotal: '0',
209
+ mrcTotal: '100',
210
+ usageEstimates: null,
211
+ usageTotalEstimated: null,
212
+ validationErrors: null,
213
+ isConfigured: true,
214
+ targetSubscriptionId: 'sub-1',
215
+ sourceSubscriptionItemId: 'item-1',
216
+ }
217
+ return { cpqConfig, lineConfig }
218
+ }
219
+
220
+ function routeEm(
221
+ em: ReturnType<typeof makePricedService>['em'],
222
+ cpqConfig: Record<string, unknown>,
223
+ lineConfig: Record<string, unknown>,
224
+ ) {
225
+ em.findOne.mockImplementation(async (entity: unknown) => {
226
+ if (entity === CpqQuoteConfiguration) return cpqConfig
227
+ if (entity === SalesQuote) return { id: 'sq-1', currencyCode: 'USD', quoteNumber: 'Q-001' }
228
+ if (entity === SalesQuoteLine) return { id: 'ql-1' }
229
+ if (entity === CpqProductOffering) return { id: 'off-1', name: 'GIX Access Port', offeringType: 'simple' }
230
+ if (entity === CpqQuoteLineConfiguration) return lineConfig
231
+ return null
232
+ })
233
+ em.find.mockImplementation(async (entity: unknown) => {
234
+ if (entity === CpqQuoteLineConfiguration) return [lineConfig]
235
+ return []
236
+ })
237
+ }
238
+
239
+ describe('recalculate → Amend mirror lines', () => {
240
+ it('re-prices a legacy Amend mirror line (created before the frozen marker) at the current standard price', async () => {
241
+ const { em, pricingService, service } = makePricedService()
242
+ const { cpqConfig, lineConfig } = amendFixture({
243
+ port_size: '1G',
244
+ _arcMirroredFromItemId: 'item-1',
245
+ _arcMirroredName: 'GIX Access Port',
246
+ })
247
+ routeEm(em, cpqConfig, lineConfig)
248
+ pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
249
+
250
+ await service.recalculate('cpq-1', { save: true }, SCOPE)
251
+
252
+ expect(pricingService.resolveProductCharges).toHaveBeenCalledTimes(1)
253
+ expect(lineConfig.mrcTotal).toBe('120')
254
+ })
255
+
256
+ it('keeps a frozen Amend mirror line at its stored price without calling the pricing engine', async () => {
257
+ const { em, pricingService, service } = makePricedService()
258
+ const { cpqConfig, lineConfig } = amendFixture({
259
+ port_size: '1G',
260
+ _arcMirroredFromItemId: 'item-1',
261
+ _arcMirroredName: 'GIX Access Port',
262
+ _arcPriceFrozen: true,
263
+ })
264
+ routeEm(em, cpqConfig, lineConfig)
265
+ pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
266
+
267
+ const result = await service.recalculate('cpq-1', { save: true }, SCOPE)
268
+
269
+ expect(pricingService.resolveProductCharges).not.toHaveBeenCalled()
270
+ expect(lineConfig.mrcTotal).toBe('100')
271
+ expect(result.lines[0].mrcTotal).toBe(100)
272
+ })
273
+ })
274
+
275
+ describe('updateQuoteItem → frozen Amend mirror lines', () => {
276
+ const FROZEN_CFG = {
277
+ port_size: '1G',
278
+ _arcMirroredFromItemId: 'item-1',
279
+ _arcMirroredName: 'GIX Access Port',
280
+ _arcPriceFrozen: true,
281
+ }
282
+
283
+ it('stays frozen when the UI resubmits an unchanged configuration', async () => {
284
+ const { em, pricingService, service } = makePricedService()
285
+ const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
286
+ routeEm(em, cpqConfig, lineConfig)
287
+ pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
288
+
289
+ await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { port_size: '1G' }, quantity: 1 }, SCOPE)
290
+
291
+ expect(pricingService.resolveProductCharges).not.toHaveBeenCalled()
292
+ expect(lineConfig.configuration).toMatchObject({ _arcPriceFrozen: true, _arcMirroredFromItemId: 'item-1' })
293
+ expect(lineConfig.mrcTotal).toBe('100')
294
+ })
295
+
296
+ it('unfreezes and re-prices when the configuration changes', async () => {
297
+ const { em, pricingService, service } = makePricedService()
298
+ const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
299
+ routeEm(em, cpqConfig, lineConfig)
300
+ pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(150)])
301
+
302
+ await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { port_size: '10G' } }, SCOPE)
303
+
304
+ expect(pricingService.resolveProductCharges).toHaveBeenCalled()
305
+ expect((lineConfig.configuration as Record<string, unknown>)._arcPriceFrozen).toBeUndefined()
306
+ expect(lineConfig.configuration).toMatchObject({ port_size: '10G', _arcMirroredFromItemId: 'item-1' })
307
+ expect(lineConfig.mrcTotal).toBe('150')
308
+ })
309
+
310
+ it('unfreezes and re-prices when the quantity changes', async () => {
311
+ const { em, pricingService, service } = makePricedService()
312
+ const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
313
+ routeEm(em, cpqConfig, lineConfig)
314
+ pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(110)])
315
+
316
+ await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { port_size: '1G' }, quantity: 2 }, SCOPE)
317
+
318
+ expect(pricingService.resolveProductCharges).toHaveBeenCalled()
319
+ expect(lineConfig.mrcTotal).toBe('220')
320
+ })
321
+
322
+ it('unfreezes and marks the line re-priced when the UI echoes the stored markers with a changed attribute', async () => {
323
+ const { em, pricingService, service } = makePricedService()
324
+ const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
325
+ routeEm(em, cpqConfig, lineConfig)
326
+ pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(150)])
327
+
328
+ // The quote page sends back the configuration it loaded — markers included.
329
+ await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { ...FROZEN_CFG, port_size: '10G' }, quantity: 1 }, SCOPE)
330
+
331
+ const stored = lineConfig.configuration as Record<string, unknown>
332
+ expect(pricingService.resolveProductCharges).toHaveBeenCalled()
333
+ expect(stored._arcPriceFrozen).toBeUndefined()
334
+ expect(stored._arcRepriced).toBe(true)
335
+ expect(stored._arcMirroredFromItemId).toBe('item-1')
336
+ expect(lineConfig.mrcTotal).toBe('150')
337
+ })
338
+
339
+ it('stays frozen when the UI echoes the stored markers unchanged', async () => {
340
+ const { em, pricingService, service } = makePricedService()
341
+ const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
342
+ routeEm(em, cpqConfig, lineConfig)
343
+
344
+ await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { ...FROZEN_CFG }, quantity: 1 }, SCOPE)
345
+
346
+ expect(pricingService.resolveProductCharges).not.toHaveBeenCalled()
347
+ expect((lineConfig.configuration as Record<string, unknown>)._arcRepriced).toBeUndefined()
348
+ })
349
+
350
+ it('never lets a client set the freeze marker', async () => {
351
+ const { em, pricingService, service } = makePricedService()
352
+ const { cpqConfig, lineConfig } = amendFixture({ port_size: '1G', _arcMirroredFromItemId: 'item-1', _arcMirroredName: 'GIX Access Port' })
353
+ routeEm(em, cpqConfig, lineConfig)
354
+ pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
355
+
356
+ await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { port_size: '1G', _arcPriceFrozen: true } }, SCOPE)
357
+
358
+ expect(pricingService.resolveProductCharges).toHaveBeenCalled()
359
+ expect((lineConfig.configuration as Record<string, unknown>)._arcPriceFrozen).toBeUndefined()
360
+ })
361
+ })
362
+
363
+ describe('recalculate → freeze marker outside Amend mirror lines', () => {
364
+ it('ignores the freeze marker on a line of a new (non-ARC) quote', async () => {
365
+ const { em, pricingService, service } = makePricedService()
366
+ const { cpqConfig, lineConfig } = amendFixture({
367
+ port_size: '1G',
368
+ _arcMirroredFromItemId: 'item-1',
369
+ _arcPriceFrozen: true,
370
+ })
371
+ cpqConfig.quoteType = 'new'
372
+ routeEm(em, cpqConfig, lineConfig)
373
+ pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
374
+
375
+ await service.recalculate('cpq-1', { save: true }, SCOPE)
376
+
377
+ expect(pricingService.resolveProductCharges).toHaveBeenCalledTimes(1)
378
+ expect(lineConfig.mrcTotal).toBe('120')
379
+ })
380
+ })
@@ -1,7 +1,9 @@
1
+ import { LockMode } from '@mikro-orm/core'
1
2
  import { DefaultCpqQuotingService, QuotingError } from '../cpqQuotingService'
2
3
  import type { TenantScope } from '../types'
3
4
 
4
5
  interface MockEm {
6
+ transactional: jest.MockedFunction<(callback: (em: MockEm) => Promise<unknown>) => Promise<unknown>>
5
7
  findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>>
6
8
  find: jest.MockedFunction<
7
9
  (entity: unknown, where: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown[]>
@@ -32,8 +34,9 @@ interface MockNumberGenerator {
32
34
 
33
35
  function createMockEm(): MockEm {
34
36
  const em: MockEm = {
37
+ transactional: jest.fn(),
35
38
  findOne: jest.fn(),
36
- find: jest.fn(),
39
+ find: jest.fn().mockResolvedValue([]),
37
40
  create: jest.fn().mockImplementation((_e, data: Record<string, unknown>) => ({ ...data, id: data.id ?? 'gen-id' })),
38
41
  persist: jest.fn(),
39
42
  assign: jest.fn().mockImplementation((entity: Record<string, unknown>, updates: Record<string, unknown>) => {
@@ -43,6 +46,7 @@ function createMockEm(): MockEm {
43
46
  flush: jest.fn().mockResolvedValue(undefined),
44
47
  fork: jest.fn(),
45
48
  }
49
+ em.transactional.mockImplementation((callback) => callback(em))
46
50
  em.fork.mockReturnValue(em)
47
51
  return em
48
52
  }
@@ -584,7 +588,7 @@ describe('DefaultCpqQuotingService.recalculate', () => {
584
588
  expect(em.flush).not.toHaveBeenCalled()
585
589
  })
586
590
 
587
- it('forks the EM when save=true (write path uses isolated UoW)', async () => {
591
+ it('locks the quote in a transaction when save=true', async () => {
588
592
  const { em, service } = makeService()
589
593
  em.findOne
590
594
  .mockResolvedValueOnce({
@@ -604,7 +608,9 @@ describe('DefaultCpqQuotingService.recalculate', () => {
604
608
 
605
609
  await service.recalculate('cpq-1', { save: true }, SCOPE)
606
610
 
607
- expect(em.fork).toHaveBeenCalledTimes(1)
611
+ expect(em.transactional).toHaveBeenCalledTimes(1)
612
+ expect(em.fork).toHaveBeenCalledWith({ clear: true, useContext: false })
613
+ expect(em.findOne.mock.calls[0][2]).toMatchObject({ lockMode: LockMode.PESSIMISTIC_WRITE })
608
614
  })
609
615
 
610
616
  it('marks status "new" when no lines exist (auto-managed status)', async () => {
@@ -655,4 +661,53 @@ describe('DefaultCpqQuotingService.recalculate', () => {
655
661
 
656
662
  expect(cpqConfig.cpqStatus).toBe('with_customer') // unchanged
657
663
  })
664
+
665
+ it.each(['accepted', 'rejected', 'cancelled'])(
666
+ 'rejects a saved recalculation on a %s (terminal) quote with 409 and persists nothing',
667
+ async (cpqStatus) => {
668
+ const { em, service } = makeService()
669
+ em.findOne.mockResolvedValueOnce({
670
+ id: 'cpq-1',
671
+ quoteId: 'sq-1',
672
+ cpqStatus,
673
+ customerId: 'cust',
674
+ currencyCode: 'USD',
675
+ version: 1,
676
+ parentQuoteId: null,
677
+ validationResult: null,
678
+ pricingSummary: null,
679
+ quoteContext: null,
680
+ })
681
+
682
+ await expect(service.recalculate('cpq-1', { save: true }, SCOPE)).rejects.toMatchObject({
683
+ status: 409,
684
+ message: `Cannot recalculate quote in '${cpqStatus}' status`,
685
+ })
686
+ expect(em.flush).not.toHaveBeenCalled()
687
+ },
688
+ )
689
+
690
+ it('still allows a read-only (save=false) recalculation on an accepted quote', async () => {
691
+ const { em, service } = makeService()
692
+ em.findOne
693
+ .mockResolvedValueOnce({
694
+ id: 'cpq-1',
695
+ quoteId: 'sq-1',
696
+ cpqStatus: 'accepted',
697
+ customerId: 'cust',
698
+ currencyCode: 'USD',
699
+ version: 1,
700
+ parentQuoteId: null,
701
+ validationResult: null,
702
+ pricingSummary: null,
703
+ quoteContext: null,
704
+ })
705
+ .mockResolvedValueOnce({ id: 'sq-1', currencyCode: 'USD', quoteNumber: 'Q-0001' })
706
+ em.find.mockResolvedValueOnce([])
707
+
708
+ const result = await service.recalculate('cpq-1', { save: false }, SCOPE)
709
+
710
+ expect(result.cpqStatus).toBe('accepted')
711
+ expect(em.flush).not.toHaveBeenCalled()
712
+ })
658
713
  })
@@ -0,0 +1,50 @@
1
+ import { summarizeOrderPricing } from '../orderPricingSummary'
2
+
3
+ const charge = (chargeType: 'nrc' | 'mrc', list: number, offer: number, standard = offer) => ({
4
+ chargeCode: `${chargeType}-fee`,
5
+ chargeName: `${chargeType} fee`,
6
+ chargeType,
7
+ pricingMethod: 'flat',
8
+ unitPrice: offer,
9
+ quantity: 1,
10
+ totalPrice: offer,
11
+ listUnitPrice: list,
12
+ listTotalPrice: list,
13
+ standardUnitPrice: standard,
14
+ standardTotalPrice: standard,
15
+ currencyCode: 'USD',
16
+ })
17
+
18
+ describe('summarizeOrderPricing', () => {
19
+ it('reproduces the quote figures: list totals, deal value over the term and total depth', () => {
20
+ const summary = summarizeOrderPricing([
21
+ { lineId: 'l1', quantity: 1, termMonths: 12, configuration: {}, charges: [charge('nrc', 890, 0), charge('mrc', 1200, 1050)] },
22
+ ], 'new', 'USD')
23
+
24
+ expect(summary.listNrcTotal).toBe(890)
25
+ expect(summary.listMrcTotal).toBe(1200)
26
+ expect(summary.dealValue).toEqual({ list: 890 + 1200 * 12, standard: 1050 * 12, offer: 1050 * 12 })
27
+ expect(summary.discountDepth.total).toBeCloseTo(1 - 12600 / 15290)
28
+ expect(summary).not.toHaveProperty('snapshotHash')
29
+ expect(summary).not.toHaveProperty('hasTimeBasedAdjustments')
30
+ })
31
+
32
+ it('leaves ARC mirror lines out of deal value, as on the quote', () => {
33
+ const summary = summarizeOrderPricing([
34
+ { lineId: 'kept', quantity: 1, termMonths: 12, configuration: { _arcMirroredFromItemId: 'item-1' }, charges: [charge('mrc', 400, 400)] },
35
+ { lineId: 'new', quantity: 1, termMonths: 12, configuration: {}, charges: [charge('mrc', 1200, 1050)] },
36
+ ], 'amend', 'USD')
37
+
38
+ expect(summary.dealValue.offer).toBe(1050 * 12)
39
+ expect(summary.listMrcTotal).toBe(1600)
40
+ })
41
+
42
+ it('tolerates legacy stored charges without code, type or waterfall fields', () => {
43
+ const summary = summarizeOrderPricing([
44
+ { lineId: 'l1', quantity: 2, termMonths: null, configuration: null, charges: [{ unitPrice: 50, totalPrice: 50 }] },
45
+ ], 'new', 'USD')
46
+
47
+ expect(summary.listMrcTotal).toBe(100)
48
+ expect(summary.dealValue.offer).toBe(50 * 2 * 12)
49
+ })
50
+ })
@@ -0,0 +1,62 @@
1
+ import { computeWaterfallSummary, type SnapshotLine } from '../priceAdjustments/pricingSnapshot'
2
+ import { resolveManualAdjustments } from '../priceAdjustments/resolveManualAdjustments'
3
+ import type { PriceAdjustment } from '../../data/price-adjustments'
4
+ const line: SnapshotLine = {
5
+ lineId: 'line', quantity: 2, termMonths: 3,
6
+ charges: [{ chargeCode: 'mrc', chargeName: 'MRC', chargeType: 'mrc', pricingMethod: 'flat', currencyCode: 'USD', quantity: 1,
7
+ listUnitPrice: 100, listTotalPrice: 100, standardUnitPrice: 90, standardTotalPrice: 90, unitPrice: 80, totalPrice: 80,
8
+ adjustments: [
9
+ { origin: 'rule', ruleCode: 'rule', ruleName: 'Rule', ruleType: 'discount_absolute', value: 10, unitPriceBefore: 100, unitPriceAfter: 90, delta: -10 },
10
+ { origin: 'manual', ruleCode: 'manual', ruleName: 'Manual', ruleType: 'discount_absolute', value: 10, unitPriceBefore: 90, unitPriceAfter: 80, delta: -10 },
11
+ ] }],
12
+ }
13
+ const row: PriceAdjustment = { id: 'a', quoteLineId: 'line', level: 'charge', chargeCode: 'mrc', adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match' }
14
+ it('includes line quantity, term horizon and separate rule/manual totals', () => {
15
+ const summary = computeWaterfallSummary([line], [row], 'USD')
16
+ expect(summary).toMatchObject({ listMrcTotal: 200, standardMrcTotal: 180, ruleDiscountTotal: 20, manualDiscountTotal: 20, dealValue: { list: 600, standard: 540, offer: 480 } })
17
+ expect(summary.discountDepth.manual).toBeCloseTo(1 - 480 / 540)
18
+ expect(summary.discountDepth.total).toBeCloseTo(0.2)
19
+ })
20
+ it('counts NRC once and excludes usage even over a long horizon', () => {
21
+ const nrc = { ...line.charges[0], chargeType: 'nrc' as const }
22
+ const usage = { ...line.charges[0], chargeType: 'usage' as const }
23
+ expect(computeWaterfallSummary([{ ...line, termMonths: 24, charges: [nrc, usage] }], [], 'USD').dealValue).toEqual({ list: 200, standard: 180, offer: 160 })
24
+ })
25
+ it('uses 12 months without a term; excludes unchanged ARC mirrors from deal value', () => {
26
+ expect(computeWaterfallSummary([{ ...line, termMonths: null }], [], 'USD').dealValue.list).toBe(2400)
27
+ expect(computeWaterfallSummary([{ ...line, includeInDeal: false }], [], 'USD').dealValue).toEqual({ list: 0, standard: 0, offer: 0 })
28
+ })
29
+ it('represents zero baselines by null and rule markups by negative depth', () => {
30
+ expect(computeWaterfallSummary([], [], 'USD').discountDepth).toEqual({ rule: null, manual: null, total: null, maxLineManual: null })
31
+ const marked = { ...line.charges[0], standardTotalPrice: 110, totalPrice: 110 }
32
+ expect(computeWaterfallSummary([{ ...line, charges: [marked] }], [], 'USD').discountDepth.rule).toBeCloseTo(-0.1)
33
+ })
34
+ it('hash ignores ordering, reason notes and authors but tracks price, quantity, horizon and adjustment changes', () => {
35
+ const other = { ...line, lineId: 'other' }
36
+ const initial = computeWaterfallSummary([line, other], [row], 'USD').snapshotHash
37
+ expect(computeWaterfallSummary([other, line], [{ ...row, reasonNote: 'Edited', reasonCode: 'other', createdByUserId: 'user' }], 'USD').snapshotHash).toBe(initial)
38
+ for (const changed of [{ ...line, quantity: 3 }, { ...line, termMonths: 6 }, { ...line, charges: [{ ...line.charges[0], totalPrice: 70 }] }]) {
39
+ expect(computeWaterfallSummary([changed, other], [row], 'USD').snapshotHash).not.toBe(initial)
40
+ }
41
+ expect(computeWaterfallSummary([line, other], [{ ...row, value: 70 }], 'USD').snapshotHash).not.toBe(initial)
42
+ })
43
+ it('normalizes legacy waterfall fields to the same hash and never changes legacy discountTotal', () => {
44
+ const legacy = { ...line.charges[0] }
45
+ delete legacy.listUnitPrice; delete legacy.listTotalPrice; delete legacy.standardUnitPrice; delete legacy.standardTotalPrice
46
+ const result = computeWaterfallSummary([{ ...line, charges: [legacy] }], [row], 'USD')
47
+ expect(result.snapshotHash).toBe(computeWaterfallSummary([line], [row], 'USD').snapshotHash)
48
+ expect(result).not.toHaveProperty('discountTotal')
49
+ })
50
+ it('stable hash and money after repeated resolver passes', () => {
51
+ const input = [{ ...line, parentLineId: null, enginePriced: true, mirrored: false }]
52
+ const first = resolveManualAdjustments(input, [row], 'USD')
53
+ const second = resolveManualAdjustments(first.lines, [row], 'USD')
54
+ expect(computeWaterfallSummary(first.lines.map((priced) => ({ ...priced, quantity: 2 })), [row], 'USD')).toEqual(computeWaterfallSummary(second.lines.map((priced) => ({ ...priced, quantity: 2 })), [row], 'USD'))
55
+ })
56
+ it('hash tracks deal eligibility and normalizes omitted eligibility to true', () => {
57
+ const baseline = computeWaterfallSummary([line], [], 'USD')
58
+ expect(computeWaterfallSummary([{ ...line, includeInDeal: true }], [], 'USD').snapshotHash).toBe(baseline.snapshotHash)
59
+ const excluded = computeWaterfallSummary([{ ...line, includeInDeal: false }], [], 'USD')
60
+ expect(excluded.dealValue.offer).toBe(0)
61
+ expect(excluded.snapshotHash).not.toBe(baseline.snapshotHash)
62
+ })
@@ -0,0 +1,35 @@
1
+ import { cloneQuoteAdjustments, removeQuoteLineTree } from '../priceAdjustments/quoteAdjustmentLifecycle'
2
+ import { CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
3
+ import { SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
4
+ const scope = { tenantId: 'tenant', organizationId: 'org' }
5
+ it('clones rows with new ids and remapped lines without changing reasons or the source', async () => {
6
+ const source = { id: 'adjustment', quoteLineId: 'old-line', level: 'charge', chargeCode: 'fee', adjustmentType: 'price_override', value: '80', currencyCode: 'USD', reasonCode: 'inactive_reason', createdByUserId: 'author', windowKind: 'relative', fromMonth: 1, toMonth: null, isActive: true }
7
+ const created: Array<Record<string, unknown>> = []
8
+ const em = { find: jest.fn().mockResolvedValue([source]), create: jest.fn((_entity: unknown, values: Record<string, unknown>) => { created.push(values); return values }), persist: jest.fn() }
9
+ await cloneQuoteAdjustments(em as never, 'source', { id: 'target', quoteId: 'new-sales' }, new Map([['old-line', 'new-line']]), scope)
10
+ expect(created[0]).toMatchObject({ quoteLineId: 'new-line', quoteConfigurationId: 'target', quoteId: 'new-sales', reasonCode: 'inactive_reason', createdByUserId: 'author', ...scope })
11
+ expect(created[0].id).not.toBe(source.id)
12
+ expect(source.quoteLineId).toBe('old-line')
13
+ expect(em.find).toHaveBeenCalledWith(CpqQuotePriceAdjustment, expect.objectContaining({ quoteConfigurationId: 'source', ...scope, deletedAt: null }), expect.objectContaining({ limit: 500 }))
14
+ })
15
+ it('keeps missing targets invalid with a fresh orphan id rather than a source-quote pointer', async () => {
16
+ const source = { id: 'adjustment', quoteLineId: 'missing', reasonCode: 'promotion' }
17
+ const create = jest.fn((_entity: unknown, values: Record<string, unknown>) => values)
18
+ await cloneQuoteAdjustments({ find: jest.fn().mockResolvedValue([source]), create, persist: jest.fn() } as never, 'source', { id: 'target', quoteId: 'sales' }, new Map(), scope)
19
+ expect(create.mock.calls[0][1].quoteLineId).not.toBe('missing')
20
+ })
21
+ it('soft-deletes the whole nested bundle subtree and its adjustments before returning', async () => {
22
+ const root = { id: 'root-config', quoteLineId: 'root', parentLineId: null, deletedAt: null }
23
+ const child = { id: 'child-config', quoteLineId: 'child', parentLineId: 'root', deletedAt: null }
24
+ const grandchild = { id: 'grandchild-config', quoteLineId: 'grandchild', parentLineId: 'child', deletedAt: null }
25
+ const unrelated = { id: 'other-config', quoteLineId: 'other', parentLineId: null, deletedAt: null }
26
+ const sales = [{ id: 'root', deletedAt: null }, { id: 'child', deletedAt: null }, { id: 'grandchild', deletedAt: null }]
27
+ const adjustments = [{ id: 'adjustment', quoteLineId: 'grandchild', deletedAt: null, isActive: true }]
28
+ const em = { find: jest.fn(async (entity: unknown) => entity === CpqQuoteLineConfiguration ? [root, child, grandchild, unrelated] : entity === SalesQuoteLine ? sales : adjustments), flush: jest.fn().mockResolvedValue(undefined) }
29
+ await removeQuoteLineTree(em as never, 'quote', root as never, scope)
30
+ for (const row of [root, child, grandchild, ...sales, ...adjustments]) expect(row.deletedAt).toBeInstanceOf(Date)
31
+ expect(unrelated.deletedAt).toBe(null)
32
+ expect(adjustments[0].isActive).toBe(false)
33
+ expect(em.flush).toHaveBeenCalled()
34
+ expect(em.find).toHaveBeenCalledWith(CpqQuotePriceAdjustment, expect.objectContaining({ quoteConfigurationId: 'quote', quoteLineId: { $in: ['root', 'child', 'grandchild'] }, ...scope }), expect.anything())
35
+ })
@@ -0,0 +1,65 @@
1
+ import { DefaultCpqQuotingService } from '../cpqQuotingService'
2
+ import { CpqProductOffering, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
3
+ import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
4
+ const scope = { tenantId: '11111111-1111-4111-8111-111111111111', organizationId: '22222222-2222-4222-8222-222222222222' }
5
+ const ids = { quote: '33333333-3333-4333-8333-333333333333', sales: '44444444-4444-4444-8444-444444444444', line: '55555555-5555-4555-8555-555555555555', adjustment: '66666666-6666-4666-8666-666666666666', user: '77777777-7777-4777-8777-777777777777' }
6
+ function setup() {
7
+ const quote = { id: ids.quote, quoteId: ids.sales, customerId: 'customer', quoteType: 'new', cpqStatus: 'new', currencyCode: 'USD', pricingRevision: 0, ...scope }
8
+ const sales = { id: ids.sales, quoteNumber: 'Q-1', currencyCode: 'USD' }
9
+ const line = { id: 'config', quoteLineId: ids.line, quoteConfigurationId: ids.quote, offeringId: 'offer', productId: null, parentLineId: null, quantity: 2, configuration: {}, nrcTotal: '0', mrcTotal: '200', isConfigured: true, charges: [], usageEstimates: null, ...scope }
10
+ const salesLine = { id: ids.line, totalNetAmount: '200' }
11
+ const row = { id: ids.adjustment, quoteConfigurationId: ids.quote, quoteId: ids.sales, quoteLineId: ids.line, level: 'charge', chargeCode: 'fee', chargeTypeFilter: null, adjustmentType: 'discount_absolute', value: '20', currencyCode: 'USD', reasonCode: 'competitive_match', reasonNote: null, windowKind: 'relative', fromMonth: 1, toMonth: null, createdByUserId: ids.user, ...scope }
12
+ const findOne = jest.fn(async (entity: unknown) => entity === CpqQuoteConfiguration ? quote : entity === SalesQuote ? sales : entity === CpqProductOffering ? { id: 'offer', name: 'Offering', offeringType: 'simple' } : entity === SalesQuoteLine ? salesLine : null)
13
+ const find = jest.fn(async (entity: unknown) => entity === CpqQuoteLineConfiguration ? [line] : entity === CpqQuotePriceAdjustment ? [row] : [])
14
+ const tx = { findOne, find, assign: jest.fn((entity: object, updates: object) => Object.assign(entity, updates)), flush: jest.fn().mockResolvedValue(undefined) }
15
+ const em = { ...tx, fork: jest.fn(), transactional: jest.fn(async (execute: (manager: typeof tx) => Promise<unknown>) => execute(tx)) }
16
+ em.fork.mockReturnValue(em)
17
+ const pricing = { resolveProductCharges: jest.fn().mockResolvedValue([{ chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'flat', unitPrice: 100, totalPrice: 100, quantity: 1, currencyCode: 'USD', adjustments: [] }]) }
18
+ const service = new DefaultCpqQuotingService({ em: em as never, cpqPricingService: pricing as never, cpqValidationService: { validateOfferingConfiguration: async () => ({ errors: [] }), validateRelationships: async () => [] } as never, cpqProductService: {} as never, salesDocumentNumberGenerator: {} as never })
19
+ return { service, quote, line, row, salesLine, em, tx }
20
+ }
21
+ it('applies stored adjustments to quote/sales totals exactly once and preserves deprecated discountTotal', async () => {
22
+ const { service, quote, line, salesLine, em } = setup()
23
+ const first = await service.recalculate(ids.quote, { save: true }, scope)
24
+ expect(first.lines[0].mrcTotal).toBe(160)
25
+ expect(first.lines[0].charges[0]).toMatchObject({ listTotalPrice: 100, standardTotalPrice: 100, totalPrice: 80 })
26
+ expect(first.pricingSummary).toMatchObject({ mrcTotal: 160, manualDiscountTotal: 40, ruleDiscountTotal: 0, discountTotal: 0 })
27
+ expect(first.pricingRevision).toBe(1)
28
+ expect(salesLine.totalNetAmount).toBe('160')
29
+ expect(line.mrcTotal).toBe('160')
30
+ const second = await service.recalculate(ids.quote, { save: true }, scope)
31
+ expect(second.lines[0].charges).toEqual(first.lines[0].charges)
32
+ expect(second.pricingSummary.snapshotHash).toBe(first.pricingSummary.snapshotHash)
33
+ expect(quote.pricingRevision).toBe(2)
34
+ expect(em.transactional).toHaveBeenCalledTimes(2)
35
+ })
36
+ it('keeps an invalid row, marks the line and quote incomplete, and charges standard', async () => {
37
+ const { service, row } = setup()
38
+ row.chargeCode = 'removed-charge'
39
+ const result = await service.recalculate(ids.quote, { save: true }, scope)
40
+ expect(result.cpqStatus).toBe('incomplete')
41
+ expect(result.lines[0].isConfigured).toBe(false)
42
+ expect(result.lines[0].mrcTotal).toBe(200)
43
+ expect(result.lines[0].validationErrors).toEqual([expect.objectContaining({ message: 'ADJUSTMENT_TARGET_MISSING' })])
44
+ expect(row.chargeCode).toBe('removed-charge')
45
+ })
46
+ it('previews saved adjustments without changing a managed entity, revision or persisted snapshot', async () => {
47
+ const { service, quote, line, tx, em } = setup()
48
+ const result = await service.recalculate(ids.quote, { save: false }, scope)
49
+ expect(result.lines[0].mrcTotal).toBe(160)
50
+ expect(quote.cpqStatus).toBe('new')
51
+ expect(quote.pricingRevision).toBe(0)
52
+ expect(quote).not.toHaveProperty('pricingSummary')
53
+ expect(line.mrcTotal).toBe('200')
54
+ expect(line.charges).toEqual([])
55
+ expect(tx.flush).not.toHaveBeenCalled()
56
+ expect(tx.assign).not.toHaveBeenCalled()
57
+ expect(em.transactional).not.toHaveBeenCalled()
58
+ })
59
+ it('loads adjustments only once per recalculation and scopes the query', async () => {
60
+ const { service, tx } = setup()
61
+ await service.recalculate(ids.quote, { save: false }, scope)
62
+ const calls = tx.find.mock.calls.filter(([entity]) => entity === CpqQuotePriceAdjustment)
63
+ expect(calls).toHaveLength(1)
64
+ expect(calls[0]).toEqual([CpqQuotePriceAdjustment, expect.objectContaining({ quoteConfigurationId: ids.quote, ...scope, deletedAt: null }), { limit: 500, orderBy: { id: 'asc' } }])
65
+ })
@@ -0,0 +1,45 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { DefaultCpqQuotingService } from '../cpqQuotingService'
3
+ import { CpqProductOffering, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
4
+ import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
5
+ const scope = { tenantId: randomUUID(), organizationId: randomUUID() }
6
+
7
+ it.each([false, true])('clones with remapped adjustments and safe pricing markers (ARC source=%s)', async (arc) => {
8
+ const quoteId = randomUUID(), salesId = randomUUID(), lineId = randomUUID(), userId = randomUUID()
9
+ const charge = { chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'flat', unitPrice: 100, totalPrice: 100, quantity: 1, currencyCode: 'USD', adjustments: [] }
10
+ const original = { id: quoteId, quoteId: salesId, customerId: userId, cpqStatus: 'ready', quoteType: arc ? 'amend' : 'new', version: 1, pricingRevision: 0, currencyCode: 'USD', ...scope }
11
+ const line = { id: randomUUID(), quoteLineId: lineId, quoteConfigurationId: quoteId, offeringId: 'offer', productId: null, parentLineId: null, quantity: 1, configuration: arc ? { _arcPriceFrozen: true, _arcMirroredFromItemId: randomUUID() } : {}, charges: [charge], mrcTotal: '100', nrcTotal: '0', isConfigured: true, deletedAt: null, ...scope }
12
+ const rows = new Map<unknown, Array<Record<string, unknown>>>([
13
+ [CpqQuoteConfiguration, [original]], [CpqQuoteLineConfiguration, [line]],
14
+ [SalesQuote, [{ id: salesId, quoteNumber: 'Q-1', customerEntityId: userId, currencyCode: 'USD', ...scope }]],
15
+ [SalesQuoteLine, [{ id: lineId, lineNumber: 1, quantity: '1', ...scope }]],
16
+ [CpqQuotePriceAdjustment, arc ? [] : [{ id: randomUUID(), quoteConfigurationId: quoteId, quoteId: salesId, quoteLineId: lineId, level: 'charge', chargeCode: 'fee', chargeTypeFilter: null, adjustmentType: 'price_override', value: '80', currencyCode: 'USD', reasonCode: 'inactive_reason', reasonNote: null, windowKind: 'relative', fromMonth: 1, toMonth: null, createdByUserId: userId, deletedAt: null, isActive: true, ...scope }]],
17
+ ])
18
+ const matches = (row: Record<string, unknown>, where: Record<string, unknown>) => Object.entries(where).every(([key, value]) =>
19
+ value === null ? row[key] == null : typeof value === 'object' ? true : row[key] === value)
20
+ const em = {
21
+ fork: jest.fn(), transactional: jest.fn(), persist: jest.fn(), flush: jest.fn().mockResolvedValue(undefined),
22
+ assign: jest.fn((entity: object, values: object) => Object.assign(entity, values)),
23
+ create: jest.fn((entity: unknown, values: Record<string, unknown>) => {
24
+ const row = { id: randomUUID(), quoteType: 'new', createdAt: new Date(), updatedAt: new Date(), deletedAt: null, ...values }
25
+ const group = rows.get(entity) ?? []; group.push(row); rows.set(entity, group); return row
26
+ }),
27
+ findOne: jest.fn(async (entity: unknown, where: Record<string, unknown>) => entity === CpqProductOffering ? { id: 'offer', name: 'Offering', offeringType: 'simple' } : (rows.get(entity) ?? []).find((row) => matches(row, where)) ?? null),
28
+ find: jest.fn(async (entity: unknown, where: Record<string, unknown>) => (rows.get(entity) ?? []).filter((row) => matches(row, where))),
29
+ }
30
+ em.fork.mockReturnValue(em)
31
+ em.transactional.mockImplementation(async (execute: (tx: typeof em) => Promise<unknown>) => execute(em))
32
+ const service = new DefaultCpqQuotingService({ em: em as never,
33
+ cpqPricingService: { resolveProductCharges: async () => [charge] } as never,
34
+ cpqValidationService: { validateOfferingConfiguration: async () => ({ errors: [] }), validateRelationships: async () => [] } as never,
35
+ cpqProductService: {} as never, salesDocumentNumberGenerator: { generate: async () => ({ number: 'Q-2' }) } as never })
36
+ const result = await service.cloneQuote(quoteId, scope)
37
+ expect(result.id).not.toBe(quoteId)
38
+ expect(result.lines[0].lineId).not.toBe(lineId)
39
+ expect(result.lines[0].configuration).not.toHaveProperty('_arcPriceFrozen')
40
+ expect(result.lines[0].charges[0].totalPrice).toBe(arc ? 100 : 80)
41
+ if (!arc) {
42
+ const copied = (rows.get(CpqQuotePriceAdjustment) ?? []).find((row) => row.quoteConfigurationId === result.id)
43
+ expect(copied).toMatchObject({ quoteLineId: result.lines[0].lineId, value: '80', reasonCode: 'inactive_reason' })
44
+ } else expect(line.configuration).toHaveProperty('_arcPriceFrozen', true)
45
+ })