@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
@@ -0,0 +1,128 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { formatDepth } from '../lib/priceAdjustmentView'
6
+ import { chargeTypeDepths, formatChangePct, type ArcRecurringDelta } from '../lib/quoteSummaryView'
7
+ import { formatMoney } from '../lib/formatMoney'
8
+
9
+ /** Pricing summary fields shared by quote and order results. Waterfall fields are absent on legacy snapshots. */
10
+ export type PricingSummaryView = {
11
+ nrcTotal: number
12
+ mrcTotal: number
13
+ listNrcTotal?: number
14
+ listMrcTotal?: number
15
+ dealValue?: { list: number; standard: number; offer: number }
16
+ discountDepth?: { total: number | null } | null
17
+ usageTotalEstimated?: number | null
18
+ usageCharges?: Array<{ chargeName: string; unitPrice: number }>
19
+ }
20
+
21
+ export function SummaryFigure({
22
+ label,
23
+ value,
24
+ note,
25
+ title,
26
+ size = 'lg',
27
+ valueClassName,
28
+ testId,
29
+ }: {
30
+ label: string
31
+ value: string
32
+ note?: string | null
33
+ title?: string
34
+ size?: 'lg' | 'sm'
35
+ valueClassName?: string
36
+ testId?: string
37
+ }) {
38
+ return (
39
+ <div data-testid={testId}>
40
+ <p className="text-xs text-muted-foreground" title={title}>{label}</p>
41
+ <p className={`${size === 'lg' ? 'text-lg' : 'text-sm'} font-semibold font-mono ${valueClassName ?? ''}`}>
42
+ {value}
43
+ {note && <span className="ml-1.5 text-xs font-normal text-muted-foreground">({note})</span>}
44
+ </p>
45
+ </div>
46
+ )
47
+ }
48
+
49
+ /**
50
+ * NRC / MRC / deal value with discount depth in brackets, plus the ARC
51
+ * current → new MRC row on Amend / Renew documents. Shared by the quote and
52
+ * order detail pages so both read the same.
53
+ */
54
+ export function PricingSummaryCard({ summary, currency, arcDelta }: {
55
+ summary: PricingSummaryView
56
+ currency: string
57
+ arcDelta?: ArcRecurringDelta | null
58
+ }) {
59
+ const t = useT()
60
+ const typeDepths = chargeTypeDepths(summary)
61
+ const usageCharges = summary.usageCharges ?? []
62
+ return (
63
+ <div className="rounded-lg border bg-card p-4" data-testid="cpq-pricing-summary">
64
+ <h3 className="text-sm font-medium mb-3">{t('cpq.quotes.summary.title', 'Pricing Summary')}</h3>
65
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
66
+ <SummaryFigure
67
+ label={t('cpq.quotes.summary.nrc', 'Non-Recurring (NRC)')}
68
+ value={formatMoney(summary.nrcTotal, currency)}
69
+ note={formatDepth(typeDepths.nrc)}
70
+ testId="cpq-summary-nrc"
71
+ />
72
+ <SummaryFigure
73
+ label={t('cpq.quotes.summary.mrc', 'Monthly Recurring (MRC)')}
74
+ value={formatMoney(summary.mrcTotal, currency)}
75
+ note={formatDepth(typeDepths.mrc)}
76
+ testId="cpq-summary-mrc"
77
+ />
78
+ {summary.dealValue && (
79
+ <SummaryFigure
80
+ label={t('cpq.priceAdjustments.summary.dealValue', 'Deal value')}
81
+ title={t('cpq.priceAdjustments.summary.hint', 'Deal value counts recurring charges over the term plus one-time charges; existing subscription items on change quotes are excluded.')}
82
+ value={formatMoney(summary.dealValue.offer, currency)}
83
+ note={formatDepth(summary.discountDepth?.total ?? null)}
84
+ testId="cpq-deal-value"
85
+ />
86
+ )}
87
+ {summary.usageTotalEstimated != null && (
88
+ <SummaryFigure
89
+ label={t('cpq.quotes.summary.usageEstimated', 'Usage (est.)')}
90
+ value={formatMoney(summary.usageTotalEstimated, currency)}
91
+ />
92
+ )}
93
+ {usageCharges.length > 0 && (
94
+ <div>
95
+ <p className="text-xs text-muted-foreground">{t('cpq.quotes.summary.usageCharges', 'Usage Charges')}</p>
96
+ {usageCharges.map((uc, i) => (
97
+ <p key={i} className="text-xs mt-0.5">{uc.chargeName}: {formatMoney(uc.unitPrice, currency)}/unit</p>
98
+ ))}
99
+ </div>
100
+ )}
101
+ </div>
102
+ {arcDelta && (
103
+ <div className="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-4 border-t pt-3" data-testid="cpq-arc-delta">
104
+ <SummaryFigure
105
+ size="sm"
106
+ testId="cpq-arc-current-mrc"
107
+ label={t('cpq.quotes.summary.arc.currentMrc', 'Current MRC')}
108
+ value={formatMoney(arcDelta.currentMrc, currency)}
109
+ />
110
+ <SummaryFigure
111
+ size="sm"
112
+ testId="cpq-arc-new-mrc"
113
+ label={t('cpq.quotes.summary.arc.newMrc', 'New MRC')}
114
+ value={formatMoney(arcDelta.newMrc, currency)}
115
+ />
116
+ <SummaryFigure
117
+ size="sm"
118
+ testId="cpq-arc-mrc-change"
119
+ label={t('cpq.quotes.summary.arc.mrcChange', 'MRC change')}
120
+ value={`${arcDelta.delta > 0 ? '+' : ''}${formatMoney(arcDelta.delta, currency)}`}
121
+ valueClassName={arcDelta.delta > 0 ? 'text-status-success-text' : arcDelta.delta < 0 ? 'text-destructive' : undefined}
122
+ note={formatChangePct(arcDelta.deltaPct)}
123
+ />
124
+ </div>
125
+ )}
126
+ </div>
127
+ )
128
+ }
@@ -0,0 +1,38 @@
1
+ import * as React from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
4
+ import { DepthBadge, PriceWaterfall } from '../PriceWaterfall'
5
+
6
+ const withI18n = (node: React.ReactNode) => <I18nProvider locale="en" dict={{}}>{node}</I18nProvider>
7
+
8
+ const text = (html: string) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
9
+
10
+ it('renders list, rule, standard, negotiated and offer steps with the reason label', () => {
11
+ const html = renderToStaticMarkup(withI18n(
12
+ <PriceWaterfall
13
+ currency="USD"
14
+ reasonLabels={{ competitive_match: 'Competitive match' }}
15
+ charge={{
16
+ chargeType: 'mrc', unitPrice: 80, totalPrice: 80, listUnitPrice: 100, standardUnitPrice: 90,
17
+ adjustments: [
18
+ { origin: 'rule', ruleCode: 'VOL', ruleName: 'Volume', ruleType: 'discount_percent', value: 10, unitPriceBefore: 100, unitPriceAfter: 90, delta: -10 },
19
+ { origin: 'manual', adjustmentId: 'a1', ruleCode: 'a1', ruleName: 'competitive_match', ruleType: 'price_override', value: 80, unitPriceBefore: 90, unitPriceAfter: 80, delta: -10, reasonCode: 'competitive_match' },
20
+ ],
21
+ }}
22
+ renderManualActions={(id) => <button>edit {id}</button>}
23
+ />,
24
+ ))
25
+ const content = text(html)
26
+ expect(content).toMatch(/List price \$100\.00/)
27
+ expect(content).toMatch(/Price rule: Volume 10%/)
28
+ expect(content).toMatch(/Standard price \$90\.00/)
29
+ expect(content).toMatch(/Negotiated: Competitive match \$80\.00/)
30
+ expect(content).toMatch(/Offer price \$80\.00/)
31
+ expect(content).toContain('edit a1')
32
+ expect(content.match(/edit/g)).toHaveLength(1)
33
+ })
34
+
35
+ it('renders a signed depth badge only when prices differ', () => {
36
+ expect(text(renderToStaticMarkup(<DepthBadge before={100} after={80} />))).toBe('−20%')
37
+ expect(renderToStaticMarkup(<DepthBadge before={100} after={100} />)).toBe('')
38
+ })
@@ -0,0 +1,35 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
5
+
6
+ type FeatureCheckResponse = { granted: string[] }
7
+
8
+ /**
9
+ * Resolve which of `features` the current user holds, through the framework's
10
+ * `POST /api/auth/feature-check` endpoint (the same one the component-override
11
+ * provider uses). Returns an empty list until the check completes, so callers
12
+ * hide privileged actions by default.
13
+ */
14
+ export function useGrantedFeatures(features: readonly string[]): readonly string[] {
15
+ const [granted, setGranted] = React.useState<readonly string[]>([])
16
+ const key = features.join('|')
17
+
18
+ React.useEffect(() => {
19
+ if (!key) return
20
+ let cancelled = false
21
+ void (async () => {
22
+ const call = await apiCall<FeatureCheckResponse>('/api/auth/feature-check', {
23
+ method: 'POST',
24
+ headers: { 'content-type': 'application/json' },
25
+ body: JSON.stringify({ features: key.split('|') }),
26
+ })
27
+ if (!cancelled && call.ok && call.result?.granted) setGranted(call.result.granted)
28
+ })()
29
+ return () => {
30
+ cancelled = true
31
+ }
32
+ }, [key])
33
+
34
+ return granted
35
+ }
@@ -0,0 +1,11 @@
1
+ import { Migration20261110213052 } from '../../migrations/Migration20261110213052'
2
+
3
+ it('generates a rollback that removes the new table before it can be recreated', () => {
4
+ const up: string[] = [], down: string[] = []
5
+ Migration20261110213052.prototype.up.call({ addSql: (sql: string) => up.push(sql) } as never)
6
+ Migration20261110213052.prototype.down.call({ addSql: (sql: string) => down.push(sql) } as never)
7
+ expect(up.join('\n')).toContain('create table "cpq_quote_price_adjustments"')
8
+ expect(down.join('\n')).toContain('drop table if exists "cpq_quote_price_adjustments"')
9
+ expect(up.join('\n')).toContain('add "pricing_revision"')
10
+ expect(down.join('\n')).toContain('drop column "pricing_revision"')
11
+ })
@@ -0,0 +1,26 @@
1
+ import { cpqPriceAdjustmentInputSchema, cpqPriceAdjustmentPatchSchema } from '../price-adjustments'
2
+ const valid = {
3
+ level: 'charge', quoteLineId: '11111111-1111-4111-8111-111111111111', chargeCode: 'setup',
4
+ adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match',
5
+ }
6
+ it('allows a zero override and percentage discounts with no currency', () => {
7
+ expect(cpqPriceAdjustmentInputSchema.parse({ ...valid, value: 0 }).value).toBe(0)
8
+ expect(cpqPriceAdjustmentInputSchema.parse({ ...valid, adjustmentType: 'discount_percent', value: 100, currencyCode: undefined }).value).toBe(100)
9
+ })
10
+ it.each([
11
+ { level: 'quote' }, { quoteLineId: 'bad' }, { chargeCode: null }, { value: -1 }, { value: Infinity }, { value: 1.23456 }, { value: 100_000_000_000_000 },
12
+ { adjustmentType: 'discount_percent', value: 101 }, { adjustmentType: 'discount_percent', value: 0 },
13
+ { adjustmentType: 'discount_absolute', value: 0 }, { currencyCode: null }, { currencyCode: 'usd' },
14
+ { reasonCode: 'other', reasonNote: '' }, { reasonNote: 'x'.repeat(501) },
15
+ { fromMonth: 2 }, { toMonth: 3 }, { windowKind: 'absolute' },
16
+ { level: 'line', chargeCode: undefined, adjustmentType: 'price_override' }, { chargeTypeFilter: 'mrc' },
17
+ ])('rejects malformed or unsupported adjustments: %j', (patch) => {
18
+ expect(cpqPriceAdjustmentInputSchema.safeParse({ ...valid, ...patch }).success).toBe(false)
19
+ })
20
+ it('accepts filtered line percentages and an explanation for other', () => {
21
+ expect(cpqPriceAdjustmentInputSchema.safeParse({ ...valid, level: 'line', chargeCode: undefined, chargeTypeFilter: 'mrc', adjustmentType: 'discount_percent', value: 20, reasonCode: 'other', reasonNote: 'One-time negotiation' }).success).toBe(true)
22
+ })
23
+ it('never accepts target changes through PATCH; merged patches are validated by the full schema', () => {
24
+ expect(cpqPriceAdjustmentPatchSchema.safeParse({ quoteLineId: valid.quoteLineId }).success).toBe(false)
25
+ expect(cpqPriceAdjustmentPatchSchema.parse({ reasonNote: 'New note' })).toEqual({ reasonNote: 'New note' })
26
+ })
@@ -631,6 +631,7 @@ export class CpqPriceRule {
631
631
  export class CpqQuoteConfiguration {
632
632
  [OptionalProps]?:
633
633
  | 'cpqStatus'
634
+ | 'pricingRevision'
634
635
  | 'version'
635
636
  | 'currencyCode'
636
637
  | 'quoteType'
@@ -653,6 +654,9 @@ export class CpqQuoteConfiguration {
653
654
  @Property({ name: 'customer_id', type: 'uuid' })
654
655
  customerId!: string
655
656
 
657
+ @Property({ name: 'pricing_revision', type: 'integer', default: 0 })
658
+ pricingRevision: number = 0
659
+
656
660
  @Property({ name: 'cpq_status', type: 'text', default: 'new' })
657
661
  cpqStatus: string = 'new'
658
662
 
@@ -1475,3 +1479,82 @@ export class CpqSubscriptionChangeLog {
1475
1479
  @Property({ name: 'deleted_at', type: Date, nullable: true })
1476
1480
  deletedAt?: Date | null
1477
1481
  }
1482
+
1483
+
1484
+ // ─── Deal-specific adjustments, separate from catalog price rules ───
1485
+ @Entity({ tableName: 'cpq_quote_price_adjustments' })
1486
+ @Index({ name: 'cpq_qpa_quote_config_idx', properties: ['organizationId', 'tenantId', 'quoteConfigurationId'] })
1487
+ @Index({ name: 'cpq_qpa_quote_line_idx', properties: ['organizationId', 'tenantId', 'quoteLineId'] })
1488
+ @Index({ name: 'cpq_qpa_quote_idx', properties: ['organizationId', 'tenantId', 'quoteId'] })
1489
+ export class CpqQuotePriceAdjustment {
1490
+ [OptionalProps]?: 'windowKind' | 'fromMonth' | 'toMonth' | 'isActive' | 'createdAt' | 'updatedAt' | 'deletedAt'
1491
+
1492
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
1493
+ id!: string
1494
+
1495
+ @Property({ name: 'organization_id', type: 'uuid' })
1496
+ organizationId!: string
1497
+
1498
+ @Property({ name: 'tenant_id', type: 'uuid' })
1499
+ tenantId!: string
1500
+
1501
+ @Property({ name: 'quote_configuration_id', type: 'uuid' })
1502
+ quoteConfigurationId!: string
1503
+
1504
+ @Property({ name: 'quote_id', type: 'uuid' })
1505
+ quoteId!: string
1506
+
1507
+ @Property({ name: 'quote_line_id', type: 'uuid' })
1508
+ quoteLineId!: string
1509
+
1510
+ @Property({ type: 'text' })
1511
+ level!: 'charge' | 'line'
1512
+
1513
+ @Property({ name: 'charge_code', type: 'text', nullable: true })
1514
+ chargeCode: string | null = null
1515
+
1516
+ @Property({ name: 'charge_type_filter', type: 'text', nullable: true })
1517
+ chargeTypeFilter: 'nrc' | 'mrc' | null = null
1518
+
1519
+ @Property({ name: 'adjustment_type', type: 'text' })
1520
+ adjustmentType!: 'discount_percent' | 'discount_absolute' | 'price_override'
1521
+
1522
+ @Property({ type: 'numeric', columnType: 'numeric(18, 4)' })
1523
+ value!: string
1524
+
1525
+ @Property({ name: 'currency_code', type: 'text', nullable: true })
1526
+ currencyCode: string | null = null
1527
+
1528
+ @Property({ name: 'window_kind', type: 'text', default: 'relative' })
1529
+ windowKind: string = 'relative'
1530
+
1531
+ @Property({ name: 'from_month', type: 'integer', default: 1 })
1532
+ fromMonth: number = 1
1533
+
1534
+ @Property({ name: 'to_month', type: 'integer', nullable: true })
1535
+ toMonth: number | null = null
1536
+
1537
+ @Property({ name: 'reason_code', type: 'text' })
1538
+ reasonCode!: string
1539
+
1540
+ @Property({ name: 'reason_note', type: 'text', nullable: true })
1541
+ reasonNote: string | null = null
1542
+
1543
+ @Property({ name: 'created_by_user_id', type: 'uuid' })
1544
+ createdByUserId!: string
1545
+
1546
+ @Property({ name: 'updated_by_user_id', type: 'uuid', nullable: true })
1547
+ updatedByUserId: string | null = null
1548
+
1549
+ @Property({ name: 'is_active', type: 'boolean', default: true })
1550
+ isActive: boolean = true
1551
+
1552
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
1553
+ createdAt: Date = new Date()
1554
+
1555
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
1556
+ updatedAt: Date = new Date()
1557
+
1558
+ @Property({ name: 'deleted_at', type: Date, nullable: true })
1559
+ deletedAt: Date | null = null
1560
+ }
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod'
2
+
3
+ export const adjustmentTypeSchema = z.enum(['discount_percent', 'discount_absolute', 'price_override'])
4
+ const adjustmentFields = {
5
+ adjustmentType: adjustmentTypeSchema,
6
+ // Match numeric(18,4), so saving never silently rounds the previewed input.
7
+ value: z.number().finite().min(0).max(99_999_999_999_999).multipleOf(0.0001),
8
+ currencyCode: z.string().regex(/^[A-Z]{3}$/).nullish(),
9
+ chargeTypeFilter: z.enum(['nrc', 'mrc']).nullish(),
10
+ reasonCode: z.string().trim().min(1).max(100),
11
+ reasonNote: z.string().trim().max(500).nullish(),
12
+ expectedRevision: z.number().int().min(0).optional(),
13
+ }
14
+ export const cpqPriceAdjustmentInputSchema = z.object({
15
+ ...adjustmentFields,
16
+ level: z.enum(['charge', 'line']),
17
+ quoteLineId: z.string().uuid(),
18
+ chargeCode: z.string().trim().min(1).max(255).nullish(),
19
+ windowKind: z.literal('relative').optional(),
20
+ fromMonth: z.literal(1).optional(),
21
+ toMonth: z.null().optional(),
22
+ }).strict().superRefine((input, ctx) => {
23
+ const error = (path: string, message: string) => ctx.addIssue({ code: 'custom', path: [path], message })
24
+ if (input.level === 'charge' && !input.chargeCode) error('chargeCode', 'TARGET_NOT_FOUND')
25
+ if (input.level === 'charge' && input.chargeTypeFilter) error('chargeTypeFilter', 'TYPE_NOT_ALLOWED')
26
+ if (input.level === 'line' && input.chargeCode) error('chargeCode', 'TYPE_NOT_ALLOWED')
27
+ if (input.level === 'line' && input.adjustmentType !== 'discount_percent') error('adjustmentType', 'TYPE_NOT_ALLOWED')
28
+ if (input.adjustmentType === 'discount_percent' && (input.value <= 0 || input.value > 100)) error('value', 'INVALID_PERCENT')
29
+ if (input.adjustmentType === 'discount_absolute' && input.value <= 0) error('value', 'INVALID_AMOUNT')
30
+ if (input.adjustmentType !== 'discount_percent' && !input.currencyCode) error('currencyCode', 'CURRENCY_REQUIRED')
31
+ if (input.reasonCode === 'other' && !input.reasonNote) error('reasonNote', 'REASON_NOTE_REQUIRED')
32
+ })
33
+ export const cpqPriceAdjustmentPatchSchema = z.object(adjustmentFields).partial().strict()
34
+ export type PriceAdjustmentInput = z.infer<typeof cpqPriceAdjustmentInputSchema>
35
+ export type PriceAdjustmentPatch = z.infer<typeof cpqPriceAdjustmentPatchSchema>
36
+
37
+ /** Pure pricing representation; persistence metadata stays outside the resolver. */
38
+ export const resolvedPriceAdjustmentSchema = z.object({
39
+ ...cpqPriceAdjustmentInputSchema.shape,
40
+ id: z.string().uuid(),
41
+ createdByUserId: z.string().uuid().optional(),
42
+ })
43
+ export type PriceAdjustment = z.infer<typeof resolvedPriceAdjustmentSchema>
44
+ export const adjustmentErrorSchema = z.object({
45
+ adjustmentId: z.string(),
46
+ quoteLineId: z.string(),
47
+ chargeCode: z.string().optional(),
48
+ code: z.enum(['ADJUSTMENT_TARGET_MISSING', 'TARGET_AMBIGUOUS', 'ABOVE_STANDARD', 'CURRENCY_MISMATCH', 'TYPE_NOT_ALLOWED', 'LINE_NOT_ELIGIBLE', 'ADJUSTMENT_OVERLAP']),
49
+ })
50
+ export type AdjustmentError = z.infer<typeof adjustmentErrorSchema>
51
+
52
+ export const cpqPriceAdjustmentPreviewSchema = z.object({
53
+ draft: cpqPriceAdjustmentInputSchema,
54
+ replaceAdjustmentId: z.string().uuid().optional(),
55
+ }).strict()
56
+
57
+ export const ADJUSTMENT_TOGGLE = 'cpq.manual_price_adjustments'
58
+ export const REASON_DICTIONARY = 'cpq.price_adjustment_reason'
59
+ export const DEFAULT_ADJUSTMENT_REASONS = [
60
+ { value: 'competitive_match', label: 'Competitive match' },
61
+ { value: 'volume_commitment', label: 'Volume commitment' },
62
+ { value: 'loyalty', label: 'Loyalty' },
63
+ { value: 'promotion', label: 'Promotion' },
64
+ { value: 'strategic_deal', label: 'Strategic deal' },
65
+ { value: 'price_correction', label: 'Price correction' },
66
+ { value: 'other', label: 'Other' },
67
+ ] as const
@@ -378,6 +378,7 @@ const cpqPricePreviewItemSchema = z.object({
378
378
  )
379
379
 
380
380
  export const cpqPricePreviewSchema = z.object({
381
+ includeWaterfall: z.boolean().optional().default(false),
381
382
  primaryItem: cpqPricePreviewItemSchema.nullish(),
382
383
  childItems: z.array(cpqPricePreviewItemSchema),
383
384
  currencyCode: z.string().min(1).optional(),
@@ -691,3 +692,5 @@ export const cpqConvertQuoteToOrderSchema = z.object({
691
692
  export const cpqOrderStatusTransitionSchema = z.object({
692
693
  targetStatus: z.string().min(1),
693
694
  })
695
+
696
+ export { cpqPriceAdjustmentInputSchema, cpqPriceAdjustmentPatchSchema } from './price-adjustments'
@@ -9,6 +9,7 @@ import { DefaultCpqQuotingService } from './services/cpqQuotingService'
9
9
  import { DefaultCpqWizardService } from './services/cpqWizardService'
10
10
  import { DefaultCpqInventoryService } from './services/cpqInventoryService'
11
11
  import { DefaultCpqOrderService } from './services/cpqOrderService'
12
+ import { CpqAdjustmentService } from './services/priceAdjustments/adjustmentService'
12
13
  import { DefaultCpqBundleService } from './services/cpqBundleService'
13
14
 
14
15
  /**
@@ -20,6 +21,11 @@ import { DefaultCpqBundleService } from './services/cpqBundleService'
20
21
  */
21
22
  export function register(container: AppContainer) {
22
23
  container.register({
24
+ cpqAdjustmentService: asFunction(() => new CpqAdjustmentService({
25
+ em: container.resolve('em'),
26
+ quotingService: container.resolve('cpqQuotingService'),
27
+ featureTogglesService: container.resolve('featureTogglesService'),
28
+ })).scoped(),
23
29
  cpqPricingService: asFunction(({ em }: { em: EntityManager }) => {
24
30
  return new DefaultCpqPricingService(em)
25
31
  }).scoped().proxy(),
@@ -1,4 +1,26 @@
1
1
  import { createModuleEvents } from '@open-mercato/shared/modules/events'
2
+ import type { EventPayloadSchemaField } from '@open-mercato/shared/modules/events'
3
+
4
+ /** Fields shared by both quote pricing signals (see `quotePricingEvents.ts`). */
5
+ const quotePricingSnapshotFields: EventPayloadSchemaField[] = [
6
+ { path: 'tenantId', type: 'text' },
7
+ { path: 'organizationId', type: 'text' },
8
+ { path: 'quoteId', type: 'text', label: 'CPQ quote id' },
9
+ { path: 'quoteType', type: 'select', label: 'Quote type' },
10
+ { path: 'cpqStatus', type: 'text', label: 'Status' },
11
+ { path: 'currencyCode', type: 'text' },
12
+ { path: 'pricingRevision', type: 'number' },
13
+ { path: 'snapshotHash', type: 'text' },
14
+ { path: 'dealValue.list', type: 'number', label: 'Deal value (list)' },
15
+ { path: 'dealValue.standard', type: 'number', label: 'Deal value (standard)' },
16
+ { path: 'dealValue.offer', type: 'number', label: 'Deal value (offer)' },
17
+ { path: 'discountDepth.rule', type: 'number', label: 'Rule discount depth', optional: true },
18
+ { path: 'discountDepth.manual', type: 'number', label: 'Manual discount depth', optional: true },
19
+ { path: 'discountDepth.total', type: 'number', label: 'Total discount depth', optional: true },
20
+ { path: 'discountDepth.maxLineManual', type: 'number', label: 'Max line manual discount depth', optional: true },
21
+ { path: 'hasTimeBasedAdjustments', type: 'boolean' },
22
+ { path: 'occurredAt', type: 'date' },
23
+ ]
2
24
 
3
25
  /**
4
26
  * CPQ Module Events.
@@ -8,8 +30,9 @@ import { createModuleEvents } from '@open-mercato/shared/modules/events'
8
30
  * modules (billing, notifications) subscribe to these to react to
9
31
  * subscription mutations driven by CPQ orders.
10
32
  *
11
- * Emission rule: events fire AFTER the activation transaction commits.
12
- * `cpqOrderService.activateOrder` is the sole emitter site.
33
+ * Emission rule: events fire AFTER their transaction commits.
34
+ * Subscription events: `cpqOrderService.activateOrder` is the sole emitter.
35
+ * Quote events: `withQuotePricingWrite` (plus `cloneQuote` for the copy).
13
36
  */
14
37
  const events = [
15
38
  {
@@ -54,6 +77,34 @@ const events = [
54
77
  category: 'lifecycle',
55
78
  clientBroadcast: true,
56
79
  },
80
+ // Quote pricing signals (manual price adjustments). Emitted after the quote
81
+ // write commits by `withQuotePricingWrite`; payload carries the pricing
82
+ // snapshot (deal value, discount depth, snapshot hash) so project workflows
83
+ // can route approvals without CPQ code.
84
+ {
85
+ id: 'cpq.quote.pricing_changed',
86
+ label: 'Quote Pricing Changed',
87
+ entity: 'quote',
88
+ category: 'custom',
89
+ clientBroadcast: true,
90
+ payloadSchema: { fields: [...quotePricingSnapshotFields, { path: 'cause', type: 'select' }] },
91
+ },
92
+ {
93
+ id: 'cpq.quote.status_changed',
94
+ label: 'Quote Status Changed',
95
+ entity: 'quote',
96
+ category: 'lifecycle',
97
+ clientBroadcast: true,
98
+ payloadSchema: {
99
+ fields: [
100
+ ...quotePricingSnapshotFields,
101
+ { path: 'fromStatus', type: 'text' },
102
+ { path: 'toStatus', type: 'text' },
103
+ { path: 'cause', type: 'select' },
104
+ { path: 'actorUserId', type: 'text', optional: true },
105
+ ],
106
+ },
107
+ },
57
108
  ] as const
58
109
 
59
110
  export const eventsConfig = createModuleEvents({
@@ -8,7 +8,7 @@ export const metadata: ModuleInfo = {
8
8
  'Generic CPQ engine — configurable product attributes, multi-charge pricing tables, and guided quoting flows.',
9
9
  author: 'App',
10
10
  license: 'Proprietary',
11
- requires: ['auth', 'directory', 'catalog', 'sales', 'customers', 'dictionaries'],
11
+ requires: ['auth', 'directory', 'catalog', 'sales', 'customers', 'dictionaries', 'feature_toggles'],
12
12
  ejectable: false,
13
13
  }
14
14
 
@@ -0,0 +1,12 @@
1
+ import { resolveAuditActor } from '../auditActor'
2
+
3
+ const user = '77777777-7777-4777-8777-777777777777'
4
+ it.each([
5
+ [{ sub: user }, user],
6
+ [{ sub: 'api_key:abc', userId: user }, user],
7
+ [{ sub: 'api_key:abc' }, null],
8
+ [{ sub: 'api_key:abc', userId: 'not-a-uuid' }, null],
9
+ [null, null],
10
+ ])('%j → %s', (auth, expected) => {
11
+ expect(resolveAuditActor(auth)).toBe(expected)
12
+ })
@@ -0,0 +1,37 @@
1
+ import { seedPriceAdjustmentDefaults } from '../priceAdjustmentDefaults'
2
+ import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
3
+ import { FeatureToggle } from '@open-mercato/core/modules/feature_toggles/data/entities'
4
+ import { DEFAULT_ADJUSTMENT_REASONS } from '../../data/price-adjustments'
5
+ import { setup } from '../../setup'
6
+ import { features } from '../../acl'
7
+ const scope = { tenantId: 'tenant', organizationId: 'org' }
8
+ it('seeds seven editable reasons idempotently without resetting a disabled rollout toggle or custom label', async () => {
9
+ let dictionary: Record<string, unknown> | null = null
10
+ const entries: Array<Record<string, unknown>> = []
11
+ const flag = { identifier: 'cpq.manual_price_adjustments', defaultValue: false }
12
+ const em = {
13
+ upsert: jest.fn(async () => flag),
14
+ findOne: jest.fn(async () => dictionary), find: jest.fn(async () => entries),
15
+ create: jest.fn((entity: unknown, values: Record<string, unknown>) => {
16
+ const row = { id: `id-${entries.length}`, ...values }
17
+ if (entity === Dictionary) dictionary = row
18
+ if (entity === DictionaryEntry) entries.push(row)
19
+ return row
20
+ }), persist: jest.fn(), flush: jest.fn().mockResolvedValue(undefined),
21
+ }
22
+ await seedPriceAdjustmentDefaults(em as never, scope)
23
+ expect(entries).toHaveLength(7)
24
+ expect(entries.map((entry) => entry.value)).toEqual(DEFAULT_ADJUSTMENT_REASONS.map((entry) => entry.value))
25
+ expect(dictionary).toMatchObject({ managerVisibility: 'default', isSystem: false, ...scope })
26
+ entries[0].label = 'Tenant custom label'
27
+ await seedPriceAdjustmentDefaults(em as never, scope)
28
+ expect(entries).toHaveLength(7)
29
+ expect(entries[0].label).toBe('Tenant custom label')
30
+ expect(flag.defaultValue).toBe(false)
31
+ expect(em.upsert).toHaveBeenCalledWith(FeatureToggle, expect.objectContaining({ defaultValue: true, createdAt: expect.any(Date), updatedAt: expect.any(Date) }), { onConflictFields: ['identifier'], onConflictAction: 'ignore' })
32
+ })
33
+ it('declares and grants adjustment permission to both sales roles', () => {
34
+ expect(features.some((entry) => entry.id === 'cpq.quotes.adjust_price')).toBe(true)
35
+ expect(setup.defaultRoleFeatures?.admin).toContain('cpq.quotes.adjust_price')
36
+ expect(setup.defaultRoleFeatures?.employee).toContain('cpq.quotes.adjust_price')
37
+ })