@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
@@ -10,7 +10,7 @@ export const metadata = {
10
10
  pageTitleKey: 'cpq.quotes.title',
11
11
  pageGroup: 'CPQ',
12
12
  pageGroupKey: 'customers~sales~cpq.nav.group',
13
- pageOrder: 90,
13
+ pageOrder: 1,
14
14
  icon: quotesIcon,
15
15
  breadcrumb: [{ label: 'CPQ Quotes', labelKey: 'cpq.quotes.title' }],
16
16
  } as const
@@ -1,6 +1,14 @@
1
1
  import type { CustomEntitySpec } from '@open-mercato/shared/modules/entities'
2
2
 
3
3
  const entities: CustomEntitySpec[] = [
4
+ {
5
+ id: 'cpq:cpq_quote_price_adjustment',
6
+ label: 'Quote Price Adjustment',
7
+ description: 'Negotiated charge or line discount on a quote.',
8
+ labelField: 'reasonCode',
9
+ showInSidebar: false,
10
+ fields: [],
11
+ },
4
12
  {
5
13
  id: 'cpq:cpq_product_specification',
6
14
  label: 'Product Specification',
@@ -2,6 +2,7 @@ import type { ModuleCli } from '@open-mercato/shared/modules/registry'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { seedCpqDemo } from './lib/seeds/demo'
5
+ import { seedPriceAdjustmentDefaults } from './lib/priceAdjustmentDefaults'
5
6
 
6
7
  /**
7
8
  * `mercato cpq seed [--reset] [--tenant <id>] [--org <id>]`
@@ -86,4 +87,50 @@ const seed: ModuleCli = {
86
87
  },
87
88
  }
88
89
 
89
- export default [seed]
90
+ /**
91
+ * `mercato cpq seed-price-adjustments [--tenant <id>] [--org <id>]`
92
+ *
93
+ * Upgrade step for tenants created before negotiated prices shipped: adds the
94
+ * `cpq.manual_price_adjustments` toggle and the `cpq.price_adjustment_reason`
95
+ * dictionary for every active organization (or one, with `--org` /
96
+ * `--tenant`). Idempotent — existing labels and toggle values are kept.
97
+ * Pair with `mercato auth sync-role-acls` for the `cpq.quotes.adjust_price` grant.
98
+ */
99
+ const seedPriceAdjustments: ModuleCli = {
100
+ command: 'seed-price-adjustments',
101
+ async run(rest) {
102
+ const args = parseArgs(rest)
103
+ const container = await createRequestContainer()
104
+ const em = container.resolve<EntityManager>('em')
105
+ const scopes = await resolveAllScopes(em, args)
106
+ if (scopes.length === 0) {
107
+ console.error('[cpq seed-price-adjustments] No matching organization found.')
108
+ return
109
+ }
110
+ let failed = 0
111
+ for (const scope of scopes) {
112
+ try {
113
+ await seedPriceAdjustmentDefaults(em.fork(), scope)
114
+ console.log(`[cpq seed-price-adjustments] tenant=${scope.tenantId} org=${scope.organizationId} ✓`)
115
+ } catch (error) {
116
+ failed++
117
+ console.error(`[cpq seed-price-adjustments] tenant=${scope.tenantId} org=${scope.organizationId} failed`, error)
118
+ }
119
+ }
120
+ if (failed) process.exitCode = 1
121
+ },
122
+ }
123
+
124
+ async function resolveAllScopes(
125
+ em: EntityManager,
126
+ args: Record<string, string | boolean>,
127
+ ): Promise<Array<{ tenantId: string; organizationId: string }>> {
128
+ const { Organization } = await import('@open-mercato/core/modules/directory/data/entities')
129
+ const where: Record<string, unknown> = { isActive: true, deletedAt: null }
130
+ if (typeof args.org === 'string') where.id = args.org
131
+ if (typeof args.tenant === 'string') where.tenant = args.tenant
132
+ const orgs = await em.find(Organization, where as never, { orderBy: { createdAt: 'asc' } as never })
133
+ return orgs.map((org) => ({ organizationId: org.id, tenantId: org.tenant.id }))
134
+ }
135
+
136
+ export default [seed, seedPriceAdjustments]
@@ -0,0 +1,54 @@
1
+ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
2
+ import { createAdjustmentCommand, updateAdjustmentCommand, deleteAdjustmentCommand } from '../price-adjustments'
3
+ const quoteId = '11111111-1111-4111-8111-111111111111', adjustmentId = '22222222-2222-4222-8222-222222222222'
4
+ const actor = '33333333-3333-4333-8333-333333333333', tenantId = '44444444-4444-4444-8444-444444444444', organizationId = '55555555-5555-4555-8555-555555555555'
5
+ const adjustment = { level: 'charge' as const, quoteLineId: quoteId, chargeCode: 'fee', adjustmentType: 'price_override' as const, value: 80, currencyCode: 'USD', reasonCode: 'competitive_match' }
6
+ function setup() {
7
+ const service = { create: jest.fn(), update: jest.fn(), remove: jest.fn(), undo: jest.fn() }
8
+ const userHasAllFeatures = jest.fn().mockResolvedValue(true)
9
+ const ctx = { auth: { sub: actor, tenantId }, selectedOrganizationId: organizationId,
10
+ container: { resolve: (name: string) => name === 'rbacService' ? { userHasAllFeatures } : service } } as unknown as CommandRuntimeContext
11
+ return { ctx, service, userHasAllFeatures }
12
+ }
13
+ it('uses authenticated actor/scope and immutable feature IDs for create/update/delete', async () => {
14
+ const { ctx, service, userHasAllFeatures } = setup()
15
+ await createAdjustmentCommand.execute({ quoteId, adjustment }, ctx)
16
+ expect(service.create).toHaveBeenCalledWith(quoteId, adjustment, actor, { tenantId, organizationId })
17
+ await updateAdjustmentCommand.execute({ quoteId, adjustmentId, patch: { value: 70 } }, ctx)
18
+ expect(service.update).toHaveBeenCalledWith(quoteId, adjustmentId, { value: 70 }, actor, { tenantId, organizationId })
19
+ await deleteAdjustmentCommand.execute({ quoteId, adjustmentId, expectedRevision: 2 }, ctx)
20
+ expect(service.remove).toHaveBeenCalledWith(quoteId, adjustmentId, actor, { tenantId, organizationId }, 2)
21
+ expect(userHasAllFeatures).toHaveBeenCalledWith(actor, ['cpq.quotes.manage', 'cpq.quotes.adjust_price'], { tenantId, organizationId })
22
+ })
23
+ it('refuses missing permissions and missing selected organization before a service write', async () => {
24
+ const { ctx, service, userHasAllFeatures } = setup()
25
+ userHasAllFeatures.mockResolvedValue(false)
26
+ await expect(createAdjustmentCommand.execute({ quoteId, adjustment }, ctx)).rejects.toMatchObject({ status: 403 })
27
+ userHasAllFeatures.mockResolvedValue(true)
28
+ await expect(createAdjustmentCommand.execute({ quoteId, adjustment }, { ...ctx, selectedOrganizationId: null })).rejects.toMatchObject({ status: 403 })
29
+ expect(service.create).not.toHaveBeenCalled()
30
+ })
31
+ it('reads undo data from the command bus redo envelope and rechecks authorization', async () => {
32
+ const { ctx, service, userHasAllFeatures } = setup()
33
+ const after = { id: adjustmentId, ...adjustment, quoteConfigurationId: quoteId, quoteId, tenantId, organizationId,
34
+ createdByUserId: actor, createdAt: '2026-11-10T00:00:00.000Z', updatedAt: '2026-11-10T00:00:00.000Z', deletedAt: null, isActive: true }
35
+ await createAdjustmentCommand.undo!({ input: { quoteId, adjustment }, ctx, logEntry: { commandPayload: { value: { undo: { quoteId, before: null, after } } } } })
36
+ expect(service.undo).toHaveBeenCalledWith(quoteId, adjustmentId, null, after, actor, { tenantId, organizationId })
37
+ userHasAllFeatures.mockResolvedValue(false)
38
+ await expect(createAdjustmentCommand.undo!({ input: { quoteId, adjustment }, ctx, logEntry: { commandPayload: { undo: { quoteId, before: null, after } } } })).rejects.toMatchObject({ status: 403 })
39
+ })
40
+ it('rejects malformed undo data instead of silently succeeding', async () => {
41
+ const { ctx, service } = setup()
42
+ await expect(deleteAdjustmentCommand.undo!({ input: { quoteId, adjustmentId }, ctx, logEntry: { commandPayload: {} } })).rejects.toMatchObject({ status: 422 })
43
+ expect(service.undo).not.toHaveBeenCalled()
44
+ })
45
+ it('authorizes an API key by its subject but records its owning user; refuses keys without a user', async () => {
46
+ const { ctx, service, userHasAllFeatures } = setup()
47
+ const keyCtx = { ...ctx, auth: { sub: 'api_key:k1', userId: actor, tenantId } } as unknown as CommandRuntimeContext
48
+ await createAdjustmentCommand.execute({ quoteId, adjustment }, keyCtx)
49
+ expect(userHasAllFeatures).toHaveBeenCalledWith('api_key:k1', ['cpq.quotes.manage', 'cpq.quotes.adjust_price'], { tenantId, organizationId })
50
+ expect(service.create).toHaveBeenCalledWith(quoteId, adjustment, actor, { tenantId, organizationId })
51
+ const orphanKey = { ...ctx, auth: { sub: 'api_key:k2', tenantId } } as unknown as CommandRuntimeContext
52
+ await expect(createAdjustmentCommand.execute({ quoteId, adjustment }, orphanKey)).rejects.toMatchObject({ status: 403 })
53
+ expect(service.create).toHaveBeenCalledTimes(1)
54
+ })
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod'
2
+ import { resolveAuditActor } from '../lib/auditActor'
3
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
+ import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
5
+ import { registerCommand, type CommandHandler, type CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
6
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
+ import { cpqPriceAdjustmentInputSchema, cpqPriceAdjustmentPatchSchema } from '../data/price-adjustments'
8
+ import { QuotingError } from '../services/cpqQuotingService'
9
+ import type { CpqAdjustmentService, AdjustmentWriteResult, AdjustmentSnapshot } from '../services/priceAdjustments/adjustmentService'
10
+
11
+ const id = z.string().uuid()
12
+ const createSchema = z.object({ quoteId: id, adjustment: cpqPriceAdjustmentInputSchema }).strict()
13
+ const updateSchema = z.object({ quoteId: id, adjustmentId: id, patch: cpqPriceAdjustmentPatchSchema }).strict()
14
+ const deleteSchema = z.object({ quoteId: id, adjustmentId: id, expectedRevision: z.number().int().min(0).optional() }).strict()
15
+ // Undo data is parsed independently of the original request and never trusted
16
+ // to supply actor/scope. Its target is checked again under the aggregate lock.
17
+ const snapshotSchema = z.object({
18
+ id, quoteConfigurationId: id, quoteId: id, tenantId: id, organizationId: id,
19
+ createdByUserId: id, createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
20
+ deletedAt: z.string().datetime().nullable(), isActive: z.boolean(),
21
+ level: z.enum(['charge', 'line']), quoteLineId: id, chargeCode: z.string().nullish(),
22
+ chargeTypeFilter: z.enum(['nrc', 'mrc']).nullish(), adjustmentType: z.enum(['discount_percent', 'discount_absolute', 'price_override']),
23
+ value: z.number(), currencyCode: z.string().nullish(), reasonCode: z.string(), reasonNote: z.string().nullish(),
24
+ })
25
+ const undoSchema = z.object({ quoteId: id, before: snapshotSchema.nullable(), after: snapshotSchema })
26
+
27
+ async function context(ctx: CommandRuntimeContext) {
28
+ // RBAC is checked for the authenticated subject (user or API key); the
29
+ // stored author must be a user id (uuid columns).
30
+ const subject = ctx.auth?.sub
31
+ const actor = resolveAuditActor(ctx.auth)
32
+ const tenantId = ctx.organizationScope?.tenantId ?? ctx.auth?.tenantId
33
+ const organizationId = ctx.selectedOrganizationId
34
+ if (!subject || !tenantId || !organizationId) throw new QuotingError(403, 'An authenticated tenant and organization are required')
35
+ if (!actor) throw new QuotingError(403, 'Price adjustments require a user or a user-bound API key')
36
+ const scope = { tenantId, organizationId }
37
+ const rbac = ctx.container.resolve<RbacService>('rbacService')
38
+ if (!(await rbac.userHasAllFeatures(subject, ['cpq.quotes.manage', 'cpq.quotes.adjust_price'], scope))) throw new QuotingError(403, 'Price adjustment permission required')
39
+ return { actor, scope, service: ctx.container.resolve<CpqAdjustmentService>('cpqAdjustmentService') }
40
+ }
41
+
42
+ async function buildLog(quoteId: string, result: AdjustmentWriteResult) {
43
+ const { translate } = await resolveTranslations()
44
+ return { actionLabel: translate('cpq.priceAdjustments.audit.adjust', 'Adjust quote price'), resourceKind: 'cpq.quote_price_adjustment', resourceId: result.adjustment.id,
45
+ tenantId: result.adjustment.tenantId, organizationId: result.adjustment.organizationId,
46
+ snapshotBefore: result.before, snapshotAfter: result.adjustment,
47
+ payload: { undo: { quoteId, before: result.before, after: result.adjustment } } }
48
+ }
49
+
50
+ async function undo({ logEntry, ctx }: Parameters<NonNullable<CommandHandler['undo']>>[0]) {
51
+ const envelope = undoSchema.safeParse(extractUndoPayload<unknown>(logEntry))
52
+ if (!envelope.success) throw new QuotingError(422, 'Invalid adjustment undo data')
53
+ const { actor, scope, service } = await context(ctx)
54
+ const { quoteId, before, after } = envelope.data
55
+ await service.undo(quoteId, after.id, before as AdjustmentSnapshot | null, after as AdjustmentSnapshot, actor, scope)
56
+ }
57
+
58
+ export const createAdjustmentCommand: CommandHandler<z.infer<typeof createSchema>, AdjustmentWriteResult> = {
59
+ id: 'cpq.quote_price_adjustment.create', isUndoable: true,
60
+ async execute(input, ctx) {
61
+ const parsed = createSchema.parse(input)
62
+ const { actor, scope, service } = await context(ctx)
63
+ return service.create(parsed.quoteId, parsed.adjustment, actor, scope)
64
+ },
65
+ buildLog: ({ input, result }) => buildLog(input.quoteId, result),
66
+ undo,
67
+ }
68
+ export const updateAdjustmentCommand: CommandHandler<z.infer<typeof updateSchema>, AdjustmentWriteResult> = {
69
+ id: 'cpq.quote_price_adjustment.update', isUndoable: true,
70
+ async execute(input, ctx) {
71
+ const parsed = updateSchema.parse(input)
72
+ const { actor, scope, service } = await context(ctx)
73
+ return service.update(parsed.quoteId, parsed.adjustmentId, parsed.patch, actor, scope)
74
+ },
75
+ buildLog: ({ input, result }) => buildLog(input.quoteId, result),
76
+ undo,
77
+ }
78
+ export const deleteAdjustmentCommand: CommandHandler<z.infer<typeof deleteSchema>, AdjustmentWriteResult> = {
79
+ id: 'cpq.quote_price_adjustment.delete', isUndoable: true,
80
+ async execute(input, ctx) {
81
+ const parsed = deleteSchema.parse(input)
82
+ const { actor, scope, service } = await context(ctx)
83
+ return service.remove(parsed.quoteId, parsed.adjustmentId, actor, scope, parsed.expectedRevision)
84
+ },
85
+ buildLog: ({ input, result }) => buildLog(input.quoteId, result),
86
+ undo,
87
+ }
88
+ registerCommand(createAdjustmentCommand)
89
+ registerCommand(updateAdjustmentCommand)
90
+ registerCommand(deleteAdjustmentCommand)
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { Tag } from '@open-mercato/ui/primitives/tag'
6
+ import { formatMoney } from '../lib/formatMoney'
7
+
8
+ export type LineAction = 'add' | 'modify' | 'cancel'
9
+
10
+ const ACTION_VARIANTS: Record<LineAction, 'info' | 'warning' | 'error'> = {
11
+ add: 'info',
12
+ modify: 'warning',
13
+ cancel: 'error',
14
+ }
15
+
16
+ /** Coloured add / modify / cancel tag on quote and order lines. */
17
+ export function LineActionBadge({ action, label, title }: { action: string; label?: string; title?: string }) {
18
+ const variant = ACTION_VARIANTS[action as LineAction] ?? 'info'
19
+ return (
20
+ <Tag variant={variant} className="px-1.5 text-xs" title={title}>
21
+ {label ?? action}
22
+ </Tag>
23
+ )
24
+ }
25
+
26
+ export type ArcDiffLine = {
27
+ action: string
28
+ quantity: number
29
+ nrcTotal: number
30
+ mrcTotal: number
31
+ arcSource: { mrcAmount: number; nrcAmount: number; quantity: number } | null
32
+ }
33
+
34
+ /** Before / after / Δ preview for a line that changes an existing subscription item. */
35
+ export function ArcLineDiff({ line, currency }: { line: ArcDiffLine; currency: string }) {
36
+ const t = useT()
37
+ const src = line.arcSource
38
+ if (!src) return null
39
+ const isCancel = line.action === 'cancel'
40
+ const rows: Array<{ label: string; before: number; after: number; asCurrency: boolean }> = [
41
+ { label: t('cpq.arc.diff.quantity', 'Quantity'), before: src.quantity, after: isCancel ? 0 : line.quantity, asCurrency: false },
42
+ { label: 'MRC', before: src.mrcAmount, after: isCancel ? 0 : line.mrcTotal, asCurrency: true },
43
+ { label: 'NRC', before: src.nrcAmount, after: isCancel ? 0 : line.nrcTotal, asCurrency: true },
44
+ ]
45
+ const fmtVal = (value: number, asCurrency: boolean) => (asCurrency ? formatMoney(value, currency) : String(value))
46
+
47
+ return (
48
+ <div className="space-y-1.5" data-testid="cpq-arc-line-diff">
49
+ <p className="text-xs font-medium text-muted-foreground">{t('cpq.arc.diff.title', 'Change Preview')}</p>
50
+ <div className="rounded border bg-background overflow-hidden">
51
+ <table className="w-full text-xs">
52
+ <thead className="bg-muted/40">
53
+ <tr className="text-muted-foreground">
54
+ <th className="text-left px-3 py-1.5 font-medium" />
55
+ <th className="text-right px-3 py-1.5 font-medium">{t('cpq.arc.diff.before', 'Before')}</th>
56
+ <th className="text-right px-3 py-1.5 font-medium">{t('cpq.arc.diff.after', 'After')}</th>
57
+ <th className="text-right px-3 py-1.5 font-medium">Δ</th>
58
+ </tr>
59
+ </thead>
60
+ <tbody>
61
+ {rows.map((r) => {
62
+ const delta = r.after - r.before
63
+ const showDelta = delta !== 0
64
+ return (
65
+ <tr key={r.label} className="border-t border-dashed border-border/40">
66
+ <td className="px-3 py-1 text-muted-foreground">{r.label}</td>
67
+ <td className="px-3 py-1 text-right font-mono">{fmtVal(r.before, r.asCurrency)}</td>
68
+ <td className={`px-3 py-1 text-right font-mono ${isCancel ? 'text-muted-foreground line-through' : ''}`}>
69
+ {fmtVal(r.after, r.asCurrency)}
70
+ </td>
71
+ <td className={`px-3 py-1 text-right font-mono font-medium ${showDelta ? (delta > 0 ? 'text-status-success-text' : 'text-destructive') : 'text-muted-foreground'}`}>
72
+ {showDelta ? `${delta > 0 ? '+' : ''}${fmtVal(delta, r.asCurrency)}` : '—'}
73
+ </td>
74
+ </tr>
75
+ )
76
+ })}
77
+ </tbody>
78
+ </table>
79
+ </div>
80
+ {isCancel && (
81
+ <p className="text-xs text-destructive italic">
82
+ {t('cpq.arc.diff.cancelNote', 'Cancellation — item will be terminated on activation.')}
83
+ </p>
84
+ )}
85
+ </div>
86
+ )
87
+ }
@@ -0,0 +1,201 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { Info } from 'lucide-react'
6
+ import { SimpleTooltip } from '@open-mercato/ui/primitives/tooltip'
7
+ import { Tag } from '@open-mercato/ui/primitives/tag'
8
+ import { DepthBadge, PriceWaterfall } from './PriceWaterfall'
9
+ import { ADJUSTMENT_ERROR_FALLBACKS, type WaterfallChargeInput } from '../lib/priceAdjustmentView'
10
+ import { formatMoney } from '../lib/formatMoney'
11
+
12
+ type TierBreakdownEntry = { tier: number; from: number; to: number; pricePerUnit: number; quantity: number; subtotal: number }
13
+
14
+ /** One priced charge as stored on a quote or order line. */
15
+ export type ChargeRowView = WaterfallChargeInput & {
16
+ chargeName: string
17
+ quantity?: number | null
18
+ note?: string | null
19
+ breakdown?: { tiers: TierBreakdownEntry[] } | null
20
+ adjustmentErrors?: Array<{ code: string }>
21
+ }
22
+
23
+ /** Right-aligned price column header with an explanatory hover tooltip. */
24
+ function PriceColumnHeader({ label, hint }: { label: string; hint: string }) {
25
+ return (
26
+ <th className="text-right py-1 font-medium">
27
+ <SimpleTooltip content={hint} size="sm">
28
+ <span className="inline-flex items-center gap-1 cursor-help" tabIndex={0} aria-label={`${label}: ${hint}`}>
29
+ {label}
30
+ <Info className="h-3 w-3" aria-hidden />
31
+ </span>
32
+ </SimpleTooltip>
33
+ </th>
34
+ )
35
+ }
36
+
37
+ /**
38
+ * Charge table shared by the quote and order detail pages: list / standard /
39
+ * offer unit prices with depth badge, and a per-charge pricing detail (tiers +
40
+ * price waterfall). Editing hooks are optional render props.
41
+ */
42
+ export function ChargeBreakdownTable<C extends ChargeRowView>({
43
+ charges,
44
+ currency,
45
+ openDetailIndex,
46
+ onToggleDetail,
47
+ reasonLabels,
48
+ renderChargeActions,
49
+ renderManualActions,
50
+ }: {
51
+ charges: C[]
52
+ currency: string
53
+ openDetailIndex: number | null
54
+ onToggleDetail: (chargeIndex: number) => void
55
+ reasonLabels?: Record<string, string>
56
+ renderChargeActions?: (charge: C) => React.ReactNode
57
+ renderManualActions?: (adjustmentId: string) => React.ReactNode
58
+ }) {
59
+ const t = useT()
60
+ return (
61
+ <table className="w-full text-xs" data-testid="cpq-charge-breakdown">
62
+ <thead>
63
+ <tr className="text-muted-foreground">
64
+ <th className="text-left py-1 font-medium">{t('cpq.charges.charge', 'Charge')}</th>
65
+ <th className="text-left py-1 font-medium">{t('cpq.charges.type', 'Type')}</th>
66
+ <PriceColumnHeader
67
+ label={t('cpq.priceAdjustments.listUnit', 'List')}
68
+ hint={t('cpq.priceAdjustments.listUnitHint', 'Catalog unit price from the pricing table, before any price rules or manual adjustments.')}
69
+ />
70
+ <PriceColumnHeader
71
+ label={t('cpq.priceAdjustments.standardUnit', 'Standard')}
72
+ hint={t('cpq.priceAdjustments.standardUnitHint', 'Unit price after automatic price rules (e.g. volume or segment discounts), before manual adjustments.')}
73
+ />
74
+ <PriceColumnHeader
75
+ label={t('cpq.priceAdjustments.offerUnit', 'Offer')}
76
+ hint={t('cpq.priceAdjustments.offerUnitHint', 'Final unit price quoted to the customer, after price rules and manual adjustments. The badge shows the total change vs list.')}
77
+ />
78
+ <th className="text-right py-1 font-medium">{t('cpq.charges.qty', 'Qty')}</th>
79
+ <th className="text-right py-1 font-medium">{t('cpq.charges.total', 'Total')}</th>
80
+ <th className="w-16" />
81
+ </tr>
82
+ </thead>
83
+ <tbody>
84
+ {charges.map((c, i) => {
85
+ const hasDetail = !!(c.breakdown?.tiers?.length || c.adjustments?.length)
86
+ const isDetailOpen = openDetailIndex === i
87
+ const listUnit = c.listUnitPrice ?? c.unitPrice
88
+ const standardUnit = c.standardUnitPrice ?? c.unitPrice
89
+ const errors = c.adjustmentErrors ?? []
90
+ return (
91
+ <React.Fragment key={i}>
92
+ <tr className="border-t border-dashed border-border/50">
93
+ <td className="py-1">
94
+ <span>{c.chargeName}</span>
95
+ {errors.length > 0 && (
96
+ <Tag variant="warning" className="ml-1.5 px-1.5 text-xs" data-testid="cpq-adjustment-invalid">
97
+ {t('cpq.priceAdjustments.invalid', 'Invalid')}: {t(`cpq.priceAdjustments.errors.${errors[0].code}`, ADJUSTMENT_ERROR_FALLBACKS[errors[0].code] ?? errors[0].code)}
98
+ </Tag>
99
+ )}
100
+ </td>
101
+ <td className="py-1 uppercase">{c.chargeType}</td>
102
+ <td className="py-1 text-right font-mono text-muted-foreground">{formatMoney(listUnit, currency)}</td>
103
+ <td className="py-1 text-right font-mono text-muted-foreground">{formatMoney(standardUnit, currency)}</td>
104
+ <td className="py-1 text-right font-mono">
105
+ <span className="inline-flex items-center gap-1">
106
+ <DepthBadge before={listUnit} after={c.unitPrice} />
107
+ {formatMoney(c.unitPrice, currency)}
108
+ </span>
109
+ </td>
110
+ <td className="py-1 text-right font-mono">{c.quantity ?? '—'}</td>
111
+ <td className="py-1 text-right font-mono">{c.totalPrice != null ? formatMoney(c.totalPrice, currency) : c.note ?? '—'}</td>
112
+ <td className="py-1 text-right whitespace-nowrap">
113
+ {renderChargeActions?.(c)}
114
+ {hasDetail && (
115
+ <button
116
+ type="button"
117
+ onClick={() => onToggleDetail(i)}
118
+ className="inline-flex items-center justify-center rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
119
+ title={t('cpq.priceAdjustments.showBreakdown', 'Show price breakdown')}
120
+ >
121
+ <Info className="h-3.5 w-3.5" />
122
+ </button>
123
+ )}
124
+ </td>
125
+ </tr>
126
+ {isDetailOpen && (
127
+ <tr>
128
+ <td colSpan={8} className="pb-3 pt-1">
129
+ <ChargePricingDetail charge={c} currency={currency} reasonLabels={reasonLabels} renderManualActions={renderManualActions} />
130
+ </td>
131
+ </tr>
132
+ )}
133
+ </React.Fragment>
134
+ )
135
+ })}
136
+ </tbody>
137
+ </table>
138
+ )
139
+ }
140
+
141
+ function ChargePricingDetail({ charge: c, currency, reasonLabels, renderManualActions }: {
142
+ charge: ChargeRowView
143
+ currency: string
144
+ reasonLabels?: Record<string, string>
145
+ renderManualActions?: (adjustmentId: string) => React.ReactNode
146
+ }) {
147
+ const t = useT()
148
+ const tiers = c.breakdown?.tiers ?? []
149
+ return (
150
+ <div className="rounded border bg-background p-3 space-y-3 text-xs">
151
+ <div className="flex items-center justify-between">
152
+ <span className="font-semibold text-sm">{t('cpq.charges.pricingDetails', 'Pricing Details')} — {c.chargeName}</span>
153
+ <span className="text-muted-foreground">{c.pricingMethod ?? 'flat'}</span>
154
+ </div>
155
+
156
+ {tiers.length > 0 && (
157
+ <div>
158
+ <div className="font-medium text-muted-foreground mb-1">{t('cpq.charges.tierBreakdown', 'Tier Breakdown')}</div>
159
+ <table className="w-full">
160
+ <thead>
161
+ <tr className="text-muted-foreground">
162
+ <th className="text-left py-0.5 font-medium">{t('cpq.charges.tier', 'Tier')}</th>
163
+ <th className="text-right py-0.5 font-medium">{t('cpq.charges.range', 'Range')}</th>
164
+ <th className="text-right py-0.5 font-medium">{t('cpq.charges.pricePerUnit', 'Price/Unit')}</th>
165
+ <th className="text-right py-0.5 font-medium">{t('cpq.charges.qty', 'Qty')}</th>
166
+ <th className="text-right py-0.5 font-medium">{t('cpq.charges.subtotal', 'Subtotal')}</th>
167
+ </tr>
168
+ </thead>
169
+ <tbody>
170
+ {tiers.map((tier) => (
171
+ <tr key={tier.tier} className="border-t border-border/30">
172
+ <td className="py-0.5">{tier.tier}</td>
173
+ <td className="py-0.5 text-right font-mono">{tier.from}–{tier.to}</td>
174
+ <td className="py-0.5 text-right font-mono">{formatMoney(tier.pricePerUnit, currency)}</td>
175
+ <td className="py-0.5 text-right font-mono">{tier.quantity}</td>
176
+ <td className="py-0.5 text-right font-mono">{formatMoney(tier.subtotal, currency)}</td>
177
+ </tr>
178
+ ))}
179
+ <tr className="border-t font-medium">
180
+ <td colSpan={4} className="py-0.5 text-right">{t('cpq.charges.tieredTotal', 'Tiered Total')}</td>
181
+ <td className="py-0.5 text-right font-mono">
182
+ {formatMoney(tiers.reduce((sum, tier) => sum + tier.subtotal, 0), currency)}
183
+ </td>
184
+ </tr>
185
+ </tbody>
186
+ </table>
187
+ </div>
188
+ )}
189
+
190
+ <div>
191
+ <div className="font-medium text-muted-foreground mb-1">{t('cpq.priceAdjustments.waterfall.title', 'Price waterfall')}</div>
192
+ <PriceWaterfall charge={c} currency={currency} reasonLabels={reasonLabels} renderManualActions={renderManualActions} />
193
+ </div>
194
+
195
+ <div className="flex items-center justify-between pt-1 border-t text-sm font-semibold">
196
+ <span>{t('cpq.charges.finalTotal', 'Final Total')}</span>
197
+ <span className="font-mono">{c.totalPrice != null ? formatMoney(c.totalPrice, currency) : '—'}</span>
198
+ </div>
199
+ </div>
200
+ )
201
+ }
@@ -0,0 +1,94 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { Tag } from '@open-mercato/ui/primitives/tag'
6
+ import {
7
+ discountDepth,
8
+ formatDepth,
9
+ waterfallRows,
10
+ type WaterfallChargeInput,
11
+ } from '../lib/priceAdjustmentView'
12
+
13
+ function money(amount: number, currency: string): string {
14
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
15
+ }
16
+
17
+ const TYPE_FALLBACKS: Record<string, string> = {
18
+ discount_percent: '% discount',
19
+ discount_absolute: 'amount off',
20
+ surcharge_percent: '% surcharge',
21
+ surcharge_absolute: 'fixed surcharge',
22
+ price_override: 'price override',
23
+ }
24
+
25
+ /** Signed depth badge: "−20%" for a discount, "+5%" for a surcharge; text, never colour only. */
26
+ export function DepthBadge({ before, after, title }: { before: number | null | undefined; after: number | null | undefined; title?: string }) {
27
+ const label = formatDepth(discountDepth(before, after))
28
+ if (!label) return null
29
+ return <Tag variant="neutral" className="px-1.5 text-xs font-mono" title={title}>{label}</Tag>
30
+ }
31
+
32
+ export type PriceWaterfallProps = {
33
+ charge: WaterfallChargeInput & { chargeName?: string }
34
+ currency: string
35
+ /** Reason code → label; manual rows fall back to the code. */
36
+ reasonLabels?: Record<string, string>
37
+ /** Optional per-adjustment actions (edit / delete) rendered on manual rows. */
38
+ renderManualActions?: (adjustmentId: string) => React.ReactNode
39
+ }
40
+
41
+ /** Read-only list → rule → standard → manual → offer breakdown for one charge (unit prices). */
42
+ export function PriceWaterfall({ charge, currency, reasonLabels, renderManualActions }: PriceWaterfallProps) {
43
+ const t = useT()
44
+ const rows = waterfallRows(charge)
45
+ const fmtValue = (ruleType: string, value: number) =>
46
+ ruleType.endsWith('_percent') ? `${value}%` : money(value, currency)
47
+ return (
48
+ <table className="w-full text-xs" data-testid="cpq-price-waterfall">
49
+ <thead>
50
+ <tr className="text-muted-foreground">
51
+ <th className="text-left py-0.5 font-medium">{t('cpq.priceAdjustments.waterfall.step', 'Step')}</th>
52
+ <th className="text-left py-0.5 font-medium">{t('cpq.priceAdjustments.waterfall.source', 'Source')}</th>
53
+ <th className="text-right py-0.5 font-medium">{t('cpq.priceAdjustments.waterfall.value', 'Value')}</th>
54
+ <th className="text-right py-0.5 font-medium">{t('cpq.priceAdjustments.waterfall.delta', 'Delta')}</th>
55
+ <th className="text-right py-0.5 font-medium">{t('cpq.priceAdjustments.waterfall.unitPrice', 'Unit price')}</th>
56
+ {renderManualActions && <th className="w-16" />}
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ {rows.map((row, index) => {
61
+ if (row.kind === 'list' || row.kind === 'standard' || row.kind === 'offer') {
62
+ const label = row.kind === 'list'
63
+ ? t('cpq.priceAdjustments.waterfall.list', 'List price')
64
+ : row.kind === 'standard'
65
+ ? t('cpq.priceAdjustments.waterfall.standard', 'Standard price')
66
+ : t('cpq.priceAdjustments.waterfall.offer', 'Offer price')
67
+ return (
68
+ <tr key={`${row.kind}-${index}`} className={`border-t border-border/30 ${row.kind === 'offer' ? 'font-semibold' : 'font-medium'}`}>
69
+ <td className="py-0.5" colSpan={4}>{label}</td>
70
+ <td className="py-0.5 text-right font-mono">{money(row.unitPrice, currency)}</td>
71
+ {renderManualActions && <td />}
72
+ </tr>
73
+ )
74
+ }
75
+ const source = row.kind === 'rule'
76
+ ? `${t('cpq.priceAdjustments.waterfall.rule', 'Price rule')}: ${row.label}`
77
+ : `${t('cpq.priceAdjustments.waterfall.manual', 'Negotiated')}: ${reasonLabels?.[row.reasonCode ?? ''] ?? row.reasonCode ?? row.label}`
78
+ return (
79
+ <tr key={`${row.kind}-${index}`} className="border-t border-border/30">
80
+ <td className="py-0.5 pl-3 text-muted-foreground">{t(`cpq.priceAdjustments.types.${row.ruleType}`, TYPE_FALLBACKS[row.ruleType] ?? row.ruleType)}</td>
81
+ <td className="py-0.5">{source}</td>
82
+ <td className="py-0.5 text-right font-mono">{fmtValue(row.ruleType, row.value)}</td>
83
+ <td className="py-0.5 text-right font-mono">{row.delta > 0 ? '+' : ''}{money(row.delta, currency)}</td>
84
+ <td className="py-0.5 text-right font-mono">{money(row.unitPriceAfter, currency)}</td>
85
+ {renderManualActions && (
86
+ <td className="py-0.5 text-right">{row.kind === 'manual' && row.adjustmentId ? renderManualActions(row.adjustmentId) : null}</td>
87
+ )}
88
+ </tr>
89
+ )
90
+ })}
91
+ </tbody>
92
+ </table>
93
+ )
94
+ }