@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,22 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20261110213052 extends Migration {
4
+
5
+ override name = 'Migration20261110213052';
6
+
7
+ override up(): void | Promise<void> {
8
+ this.addSql(`create table "cpq_quote_price_adjustments" ("id" uuid not null default gen_random_uuid(), "organization_id" uuid not null, "tenant_id" uuid not null, "quote_configuration_id" uuid not null, "quote_id" uuid not null, "quote_line_id" uuid not null, "level" text not null, "charge_code" text null, "charge_type_filter" text null, "adjustment_type" text not null, "value" numeric(18, 4) not null, "currency_code" text null, "window_kind" text not null default 'relative', "from_month" int not null default 1, "to_month" int null, "reason_code" text not null, "reason_note" text null, "created_by_user_id" uuid not null, "updated_by_user_id" uuid null, "is_active" boolean not null default true, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, primary key ("id"));`);
9
+ this.addSql(`create index "cpq_qpa_quote_idx" on "cpq_quote_price_adjustments" ("organization_id", "tenant_id", "quote_id");`);
10
+ this.addSql(`create index "cpq_qpa_quote_line_idx" on "cpq_quote_price_adjustments" ("organization_id", "tenant_id", "quote_line_id");`);
11
+ this.addSql(`create index "cpq_qpa_quote_config_idx" on "cpq_quote_price_adjustments" ("organization_id", "tenant_id", "quote_configuration_id");`);
12
+
13
+ this.addSql(`alter table "cpq_quote_configurations" add "pricing_revision" int not null default 0;`);
14
+ }
15
+
16
+ override down(): void | Promise<void> {
17
+ this.addSql(`drop table if exists "cpq_quote_price_adjustments" cascade;`);
18
+
19
+ this.addSql(`alter table "cpq_quote_configurations" drop column "pricing_revision";`);
20
+ }
21
+
22
+ }
@@ -15,6 +15,8 @@ import type { TenantScope } from '../../types'
15
15
  * pre-existing tests that only inspect `em.create.mock.calls` are unaffected.
16
16
  */
17
17
  export interface MockEm {
18
+ fork: jest.MockedFunction<() => MockEm>
19
+ transactional: jest.MockedFunction<(callback: (em: MockEm) => Promise<unknown>) => Promise<unknown>>
18
20
  findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>) => Promise<unknown>>
19
21
  find: jest.MockedFunction<
20
22
  (
@@ -39,9 +41,11 @@ export interface MockEm {
39
41
  let __syntheticIdSequence = 0
40
42
 
41
43
  export function createMockEm(): MockEm {
42
- return {
44
+ const em: MockEm = {
45
+ fork: jest.fn(),
46
+ transactional: jest.fn(),
43
47
  findOne: jest.fn(),
44
- find: jest.fn(),
48
+ find: jest.fn().mockResolvedValue([]),
45
49
  findAndCount: jest.fn(),
46
50
  count: jest.fn().mockResolvedValue(0),
47
51
  create: jest.fn().mockImplementation((_e, data: Record<string, unknown>) => ({
@@ -51,6 +55,9 @@ export function createMockEm(): MockEm {
51
55
  persist: jest.fn(),
52
56
  flush: jest.fn().mockResolvedValue(undefined),
53
57
  }
58
+ em.fork.mockReturnValue(em)
59
+ em.transactional.mockImplementation((callback) => callback(em))
60
+ return em
54
61
  }
55
62
 
56
63
  export const TEST_SCOPE: TenantScope = {
@@ -0,0 +1,183 @@
1
+ import { CpqAdjustmentService } from '../priceAdjustments/adjustmentService'
2
+ import { CpqOrderConfiguration, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
3
+ import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
4
+ import { adjustmentInput } from '../priceAdjustments/adjustmentService'
5
+ import { resolveManualAdjustments } from '../priceAdjustments/resolveManualAdjustments'
6
+ import type { PriceAdjustmentInput, PriceAdjustment } from '../../data/price-adjustments'
7
+ const scope = { tenantId: '11111111-1111-4111-8111-111111111111', organizationId: '22222222-2222-4222-8222-222222222222' }
8
+ const quoteId = '33333333-3333-4333-8333-333333333333', lineId = '44444444-4444-4444-8444-444444444444', actor = '55555555-5555-4555-8555-555555555555'
9
+ const input: PriceAdjustmentInput = { level: 'charge', quoteLineId: lineId, chargeCode: 'fee', adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match' }
10
+ function setup() {
11
+ const quote = { id: quoteId, quoteId, ...scope, cpqStatus: 'ready', pricingRevision: 0, quoteType: 'new' }
12
+ const rows: CpqQuotePriceAdjustment[] = []
13
+ const state = { enabled: true, order: false, reason: true, target: true, mirrored: false }
14
+ const em = {
15
+ fork: jest.fn(), transactional: jest.fn(), flush: jest.fn().mockResolvedValue(undefined),
16
+ assign: jest.fn((entity: object, values: object) => Object.assign(entity, values)),
17
+ create: jest.fn((_entity: unknown, values: object) => ({ fromMonth: 1, toMonth: null, windowKind: 'relative', isActive: true, deletedAt: null, createdAt: new Date(), updatedAt: new Date(), ...values })),
18
+ persist: jest.fn((row: CpqQuotePriceAdjustment) => { if (!rows.includes(row)) rows.push(row) }),
19
+ find: jest.fn(async (entity: unknown) => entity === DictionaryEntry ? [{ value: 'competitive_match', label: 'Competitive match' }] : rows.filter((row) => !row.deletedAt)),
20
+ count: jest.fn(async () => rows.filter((row) => !row.deletedAt).length),
21
+ findOne: jest.fn(async (entity: unknown, where: Record<string, unknown>) => {
22
+ if (entity === CpqQuoteConfiguration) return quote
23
+ if (entity === CpqOrderConfiguration) return state.order ? { id: 'order' } : null
24
+ if (entity === CpqQuoteLineConfiguration) return state.target ? { offeringId: 'offer', configuration: state.mirrored ? { _arcMirroredFromItemId: 'item' } : {} } : null
25
+ if (entity === Dictionary) return { id: 'dictionary' }
26
+ if (entity === DictionaryEntry) return state.reason ? { value: where.value } : null
27
+ if (entity === CpqQuotePriceAdjustment) return rows.find((row) => row.id === where.id && (where.deletedAt === undefined || row.deletedAt === null)) ?? null
28
+ return null
29
+ }),
30
+ }
31
+ em.fork.mockReturnValue(em)
32
+ em.transactional.mockImplementation(async (execute: (tx: typeof em) => Promise<unknown>) => execute(em))
33
+ const recalculateLockedQuote = jest.fn(async (_em?: unknown, _quote?: unknown, _scope?: unknown, _save?: boolean, draft?: PriceAdjustment[]) => {
34
+ const resolved = resolveManualAdjustments([{ lineId, parentLineId: null, enginePriced: true, mirrored: false,
35
+ charges: [{ chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'flat', currencyCode: 'USD', unitPrice: 100, totalPrice: 100, quantity: 1 }] }],
36
+ draft ?? rows.filter((row) => !row.deletedAt).map((row) => ({ ...adjustmentInput(row), id: row.id })), 'USD')
37
+ return { id: quoteId, adjustmentErrors: resolved.errors, lines: resolved.lines }
38
+ })
39
+ const service = new CpqAdjustmentService({ em: em as never, quotingService: { recalculateLockedQuote } as never,
40
+ featureTogglesService: { getBoolConfig: async () => ({ ok: true, value: state.enabled }) } as never })
41
+ return { service, em, quote, rows, state, recalculateLockedQuote }
42
+ }
43
+ it('creates a negotiated price and undoes it under the same pricing lock', async () => {
44
+ const { service, rows, quote, em } = setup()
45
+ const created = await service.create(quoteId, { ...input, expectedRevision: 0 }, actor, scope)
46
+ expect(created.before).toBe(null)
47
+ expect(created.adjustment).toMatchObject({ value: 80, quoteConfigurationId: quoteId, createdByUserId: actor, ...scope })
48
+ expect(quote.pricingRevision).toBe(1)
49
+ await service.undo(quoteId, created.adjustment.id, null, created.adjustment, actor, scope)
50
+ expect(rows[0].deletedAt).toBeInstanceOf(Date)
51
+ expect(quote.pricingRevision).toBe(2)
52
+ expect(em.fork).toHaveBeenCalledWith({ clear: true, useContext: false })
53
+ })
54
+ it('updates and restores previous values, accepting reordered JSON undo keys', async () => {
55
+ const { service, rows } = setup()
56
+ const created = await service.create(quoteId, input, actor, scope)
57
+ const updated = await service.update(quoteId, created.adjustment.id, { value: 70 }, actor, scope)
58
+ const reordered = Object.fromEntries(Object.entries(updated.adjustment).reverse()) as typeof updated.adjustment
59
+ await service.undo(quoteId, updated.adjustment.id, updated.before, reordered, actor, scope)
60
+ expect(rows[0].value).toBe('80')
61
+ })
62
+ it('deletes and restores a row, preserving its original reason even if no longer selectable', async () => {
63
+ const { service, rows, state } = setup()
64
+ const created = await service.create(quoteId, input, actor, scope)
65
+ state.reason = false
66
+ const removed = await service.remove(quoteId, created.adjustment.id, actor, scope)
67
+ expect(rows[0].isActive).toBe(false)
68
+ await service.undo(quoteId, removed.adjustment.id, removed.before, removed.adjustment, actor, scope)
69
+ expect(rows[0].isActive).toBe(true)
70
+ expect(rows[0].deletedAt).toBe(null)
71
+ })
72
+ it.each(['in_approval', 'accepted', 'cancelled', 'with_customer'])('refuses create and undo in locked status %s', async (status) => {
73
+ const { service, quote } = setup()
74
+ const created = await service.create(quoteId, input, actor, scope)
75
+ quote.cpqStatus = status
76
+ await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 409, message: 'ADJUSTMENT_LOCKED' })
77
+ await expect(service.undo(quoteId, created.adjustment.id, null, created.adjustment, actor, scope)).rejects.toMatchObject({ status: 409 })
78
+ })
79
+ it('refuses writes when converted or disabled', async () => {
80
+ const { service, state, em } = setup()
81
+ state.enabled = false
82
+ await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 403 })
83
+ expect(em.transactional).not.toHaveBeenCalled()
84
+ state.enabled = true; state.order = true
85
+ await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 409 })
86
+ })
87
+ it('rejects invalid prices, missing reasons, overlaps and stale revisions', async () => {
88
+ const { service, state } = setup()
89
+ await expect(service.create(quoteId, { ...input, value: 120 }, actor, scope)).rejects.toMatchObject({ status: 422, message: 'ABOVE_STANDARD' })
90
+ state.reason = false
91
+ await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 422, message: 'REASON_NOT_FOUND' })
92
+ const clean = setup()
93
+ await clean.service.create(quoteId, input, actor, scope)
94
+ await expect(clean.service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 422, message: 'ADJUSTMENT_OVERLAP' })
95
+ await expect(clean.service.create(quoteId, { ...input, expectedRevision: 0 }, actor, scope)).rejects.toMatchObject({ status: 409, message: 'STALE_REVISION' })
96
+ })
97
+ it('allows deleting an invalid adjustment while submitted', async () => {
98
+ const { service, rows, quote } = setup()
99
+ const created = await service.create(quoteId, input, actor, scope)
100
+ rows[0].value = '120'; quote.cpqStatus = 'in_approval'
101
+ await service.remove(quoteId, created.adjustment.id, actor, scope)
102
+ expect(rows[0].deletedAt).toBeInstanceOf(Date)
103
+ })
104
+ it('refuses undo if the row changed afterward or the stored tenant is wrong', async () => {
105
+ const { service, rows } = setup()
106
+ const created = await service.create(quoteId, input, actor, scope)
107
+ rows[0].value = '70'
108
+ await expect(service.undo(quoteId, created.adjustment.id, null, created.adjustment, actor, scope)).rejects.toMatchObject({ status: 409 })
109
+ await expect(service.undo(quoteId, created.adjustment.id, null, { ...created.adjustment, tenantId: actor }, actor, scope)).rejects.toMatchObject({ status: 403 })
110
+ })
111
+ it('rejects missing/out-of-scope and ARC mirror targets before persisting', async () => {
112
+ const { service, state, quote, em } = setup()
113
+ quote.quoteType = 'amend'
114
+ state.target = false
115
+ await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 404 })
116
+ state.target = true; state.mirrored = true
117
+ await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 422, message: 'LINE_NOT_ELIGIBLE' })
118
+ expect(em.persist).not.toHaveBeenCalled()
119
+ })
120
+ it('scopes and paginates reads while reporting the revision and toggle state', async () => {
121
+ const { service, em } = setup()
122
+ await service.create(quoteId, input, actor, scope)
123
+ const listed = await service.list(quoteId, scope, 1, 25)
124
+ expect(listed).toMatchObject({ total: 1, page: 1, pageSize: 25, pricingRevision: 1, enabled: true })
125
+ expect(listed.reasons).toEqual([{ value: 'competitive_match', label: 'Competitive match' }])
126
+ expect(em.find).toHaveBeenCalledWith(CpqQuotePriceAdjustment, expect.objectContaining({ quoteConfigurationId: quoteId, ...scope, deletedAt: null, isActive: true }), { limit: 25, offset: 0, orderBy: { createdAt: 'asc', id: 'asc' } })
127
+ })
128
+ it('previews a draft without persisting rows, revision, or any flush', async () => {
129
+ const { service, em, quote, rows, recalculateLockedQuote } = setup()
130
+ const preview = await service.preview(quoteId, input, undefined, scope)
131
+ expect(preview.errors).toEqual([])
132
+ expect(preview.quote.lines[0].charges[0].totalPrice).toBe(80)
133
+ expect(rows).toEqual([])
134
+ expect(quote.pricingRevision).toBe(0)
135
+ expect(em.flush).not.toHaveBeenCalled()
136
+ expect(em.persist).not.toHaveBeenCalled()
137
+ expect(em.transactional).not.toHaveBeenCalled()
138
+ expect(recalculateLockedQuote).toHaveBeenCalledWith(em, quote, scope, false, expect.any(Array))
139
+ })
140
+ it('returns invalid draft errors without saving and refuses a nonexistent replacement', async () => {
141
+ const { service, em } = setup()
142
+ const preview = await service.preview(quoteId, { ...input, value: 120 }, undefined, scope)
143
+ expect(preview.errors).toEqual([expect.objectContaining({ code: 'ABOVE_STANDARD' })])
144
+ await expect(service.preview(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 404 })
145
+ expect(em.flush).not.toHaveBeenCalled()
146
+ })
147
+ it('allows a new quote cloned from an ARC mirror to negotiate its engine price', async () => {
148
+ const { service, state } = setup()
149
+ state.mirrored = true
150
+ const result = await service.create(quoteId, input, actor, scope)
151
+ expect(result.adjustment.value).toBe(80)
152
+ })
153
+ it('refuses an overlapping adjustment at write time, even when the stored one is currently invalid', async () => {
154
+ const { service, rows, quote } = setup()
155
+ // 120 > standard 100 → ABOVE_STANDARD would reject it; store it directly as a pre-existing invalid row.
156
+ rows.push({ id: '66666666-6666-4666-8666-666666666666', quoteConfigurationId: quoteId, quoteId, ...scope, level: 'charge', quoteLineId: lineId,
157
+ chargeCode: 'fee', chargeTypeFilter: null, adjustmentType: 'price_override', value: '120', currencyCode: 'USD', reasonCode: 'competitive_match',
158
+ reasonNote: null, windowKind: 'relative', fromMonth: 1, toMonth: null, createdByUserId: actor, isActive: true, deletedAt: null,
159
+ createdAt: new Date(), updatedAt: new Date() } as unknown as CpqQuotePriceAdjustment)
160
+ await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 422, message: 'ADJUSTMENT_OVERLAP' })
161
+ expect(rows).toHaveLength(1)
162
+ expect(quote.pricingRevision).toBe(1) // bumped in the rolled-back transaction only
163
+ })
164
+ it('allows a recurring and a one-time line discount side by side, but not two covering all charges', async () => {
165
+ const { adjustmentsOverlap } = await import('../priceAdjustments/adjustmentService')
166
+ const base = { level: 'line', quoteLineId: 'l1', chargeCode: null }
167
+ expect(adjustmentsOverlap({ ...base, id: 'a', chargeTypeFilter: 'mrc' }, { ...base, id: 'b', chargeTypeFilter: 'nrc' })).toBe(false)
168
+ expect(adjustmentsOverlap({ ...base, id: 'a', chargeTypeFilter: 'mrc' }, { ...base, id: 'b', chargeTypeFilter: null })).toBe(true)
169
+ expect(adjustmentsOverlap({ ...base, id: 'a', chargeTypeFilter: null }, { ...base, id: 'a', chargeTypeFilter: null })).toBe(false)
170
+ expect(adjustmentsOverlap({ id: 'a', level: 'charge', quoteLineId: 'l1', chargeCode: 'fee' }, { id: 'b', level: 'charge', quoteLineId: 'l1', chargeCode: 'setup' })).toBe(false)
171
+ expect(adjustmentsOverlap({ id: 'a', level: 'charge', quoteLineId: 'l1', chargeCode: 'fee' }, { id: 'b', level: 'line', quoteLineId: 'l1' })).toBe(false)
172
+ })
173
+ it('refuses to undo a delete when a rep has since added an overlapping adjustment', async () => {
174
+ const { service, rows } = setup()
175
+ const first = await service.create(quoteId, input, actor, scope)
176
+ await service.remove(quoteId, first.adjustment.id, actor, scope)
177
+ expect(rows[0].isActive).toBe(false)
178
+ await service.create(quoteId, { ...input, value: 70 }, actor, scope)
179
+ const deletedSnapshot = { ...first.adjustment, deletedAt: rows[0].deletedAt!.toISOString(), isActive: false, updatedAt: rows[0].updatedAt.toISOString() }
180
+ await expect(service.undo(quoteId, first.adjustment.id, first.adjustment, deletedSnapshot, actor, scope))
181
+ .rejects.toMatchObject({ status: 422, message: 'ADJUSTMENT_OVERLAP' })
182
+ expect(rows.filter((row) => row.isActive)).toHaveLength(1)
183
+ })
@@ -0,0 +1,109 @@
1
+ import {
2
+ collectLineSubtree,
3
+ isArcCancelledLine,
4
+ planArcLineRemoval,
5
+ planArcLineRestore,
6
+ removalCancelsItem,
7
+ type ArcRemovalLine,
8
+ } from '../arcLineRemoval'
9
+
10
+ const mirror = (quoteLineId: string, parentLineId: string | null = null, action = 'modify'): ArcRemovalLine => ({
11
+ quoteLineId,
12
+ parentLineId,
13
+ action,
14
+ configuration: { _arcMirroredFromItemId: `item-${quoteLineId}`, _arcMirroredName: quoteLineId },
15
+ })
16
+ const added = (quoteLineId: string, parentLineId: string | null = null): ArcRemovalLine => ({
17
+ quoteLineId,
18
+ parentLineId,
19
+ action: 'add',
20
+ configuration: { port_speed: '10G' },
21
+ })
22
+
23
+ describe('removalCancelsItem', () => {
24
+ it.each(['amend', 'renew'])('is true for a mirror line on a %s quote', (quoteType) => {
25
+ expect(removalCancelsItem(mirror('a'), quoteType)).toBe(true)
26
+ })
27
+
28
+ it('is false for a line the quote added', () => {
29
+ expect(removalCancelsItem(added('a'), 'amend')).toBe(false)
30
+ })
31
+
32
+ it.each(['new', 'cancel', null])('is false on a %s quote', (quoteType) => {
33
+ expect(removalCancelsItem(mirror('a'), quoteType)).toBe(false)
34
+ })
35
+ })
36
+
37
+ describe('isArcCancelledLine', () => {
38
+ it('is true for a cancel line on an Amend / Renew quote', () => {
39
+ expect(isArcCancelledLine({ action: 'cancel' }, 'amend')).toBe(true)
40
+ expect(isArcCancelledLine({ action: 'cancel' }, 'renew')).toBe(true)
41
+ })
42
+
43
+ it('is false on a Cancel quote, where every line is a cancel line and keeps its price', () => {
44
+ expect(isArcCancelledLine({ action: 'cancel' }, 'cancel')).toBe(false)
45
+ })
46
+
47
+ it('is false for modify / add lines', () => {
48
+ expect(isArcCancelledLine({ action: 'modify' }, 'amend')).toBe(false)
49
+ expect(isArcCancelledLine({ action: 'add' }, 'amend')).toBe(false)
50
+ })
51
+ })
52
+
53
+ describe('collectLineSubtree', () => {
54
+ it('returns the root and every descendant, root first', () => {
55
+ const lines = [mirror('root'), mirror('child', 'root'), mirror('grandchild', 'child'), mirror('other')]
56
+ expect(collectLineSubtree(lines[0], lines).map((l) => l.quoteLineId)).toEqual(['root', 'child', 'grandchild'])
57
+ })
58
+
59
+ it('terminates on a parent cycle', () => {
60
+ const a = mirror('a', 'b')
61
+ const b = mirror('b', 'a')
62
+ expect(collectLineSubtree(a, [a, b]).map((l) => l.quoteLineId)).toEqual(['a', 'b'])
63
+ })
64
+ })
65
+
66
+ describe('planArcLineRemoval', () => {
67
+ it('cancels a single mirror line and deletes nothing', () => {
68
+ const line = mirror('port')
69
+ expect(planArcLineRemoval(line, [line, added('new-port')], 'amend')).toEqual({ cancel: [line], removeRoots: [] })
70
+ })
71
+
72
+ it('cancels mirrored components with their bundle and deletes components this quote added', () => {
73
+ const bundle = mirror('bundle')
74
+ const existingComponent = mirror('comp-1', 'bundle')
75
+ const newComponent = added('comp-2', 'bundle')
76
+ const newGrandchild = added('comp-2a', 'comp-2')
77
+ const plan = planArcLineRemoval(bundle, [bundle, existingComponent, newComponent, newGrandchild], 'amend')
78
+ expect(plan.cancel.map((l) => l.quoteLineId)).toEqual(['bundle', 'comp-1'])
79
+ // Only the subtree root is deleted — removing it takes its descendants with it.
80
+ expect(plan.removeRoots.map((l) => l.quoteLineId)).toEqual(['comp-2'])
81
+ })
82
+
83
+ it('leaves components that are already cancelled alone', () => {
84
+ const bundle = mirror('bundle')
85
+ const cancelled = mirror('comp-1', 'bundle', 'cancel')
86
+ expect(planArcLineRemoval(bundle, [bundle, cancelled], 'amend').cancel).toEqual([bundle])
87
+ })
88
+ })
89
+
90
+ describe('planArcLineRestore', () => {
91
+ it('restores the line and its cancelled mirrored components', () => {
92
+ const bundle = mirror('bundle', null, 'cancel')
93
+ const component = mirror('comp-1', 'bundle', 'cancel')
94
+ const plan = planArcLineRestore(bundle, [bundle, component], 'amend')
95
+ expect(plan).toEqual({ ok: true, restore: [bundle, component] })
96
+ })
97
+
98
+ it('is blocked while the parent item is still marked for removal', () => {
99
+ const bundle = mirror('bundle', null, 'cancel')
100
+ const component = mirror('comp-1', 'bundle', 'cancel')
101
+ expect(planArcLineRestore(component, [bundle, component], 'amend')).toEqual({ ok: false, reason: 'parent_cancelled' })
102
+ })
103
+
104
+ it('restores a component on its own when the parent stays', () => {
105
+ const bundle = mirror('bundle')
106
+ const component = mirror('comp-1', 'bundle', 'cancel')
107
+ expect(planArcLineRestore(component, [bundle, component], 'amend')).toEqual({ ok: true, restore: [component] })
108
+ })
109
+ })
@@ -0,0 +1,141 @@
1
+ import {
2
+ ARC_PRICE_FROZEN_KEY,
3
+ arcPricingInputsChanged,
4
+ buildArcMirrorConfiguration,
5
+ ARC_REPRICED_KEY,
6
+ isArcPriceFrozen,
7
+ isFrozenAmendMirrorLine,
8
+ stripArcMarkers,
9
+ stripQuoteOnlyArcMarkers,
10
+ } from '../arcPriceFreeze'
11
+
12
+ const ITEM = { id: 'item-1', name: 'GIX Access Port' }
13
+
14
+ describe('buildArcMirrorConfiguration', () => {
15
+ it('freezes Amend mirror lines and keeps the item configuration', () => {
16
+ const cfg = buildArcMirrorConfiguration({ port_size: '1G' }, ITEM, 'amend')
17
+ expect(cfg).toEqual({
18
+ port_size: '1G',
19
+ _arcMirroredFromItemId: 'item-1',
20
+ _arcMirroredName: 'GIX Access Port',
21
+ [ARC_PRICE_FROZEN_KEY]: true,
22
+ })
23
+ expect(isArcPriceFrozen(cfg)).toBe(true)
24
+ })
25
+
26
+ it.each(['renew', 'cancel'] as const)('does not freeze %s mirror lines', (quoteType) => {
27
+ const cfg = buildArcMirrorConfiguration({ port_size: '1G' }, ITEM, quoteType)
28
+ expect(isArcPriceFrozen(cfg)).toBe(false)
29
+ expect(cfg._arcMirroredFromItemId).toBe('item-1')
30
+ })
31
+
32
+ it('never inherits stale ARC markers from the item configuration', () => {
33
+ const stale = { port_size: '1G', _arcPriceFrozen: true, _arcMirroredFromItemId: 'old-item', _arcMirroredName: 'Old' }
34
+ const renew = buildArcMirrorConfiguration(stale, ITEM, 'renew')
35
+ expect(isArcPriceFrozen(renew)).toBe(false)
36
+ expect(renew).toEqual({ port_size: '1G', _arcMirroredFromItemId: 'item-1', _arcMirroredName: 'GIX Access Port' })
37
+ })
38
+
39
+ it('handles an item without configuration', () => {
40
+ expect(buildArcMirrorConfiguration(null, ITEM, 'amend')).toMatchObject({ _arcMirroredName: 'GIX Access Port' })
41
+ })
42
+ })
43
+
44
+ describe('isArcPriceFrozen', () => {
45
+ it('is false for missing or non-boolean markers', () => {
46
+ expect(isArcPriceFrozen(null)).toBe(false)
47
+ expect(isArcPriceFrozen({})).toBe(false)
48
+ expect(isArcPriceFrozen({ [ARC_PRICE_FROZEN_KEY]: 'true' })).toBe(false)
49
+ })
50
+ })
51
+
52
+ describe('arcPricingInputsChanged', () => {
53
+ const before = {
54
+ configuration: { port_size: '1G', data_centre: 'ld5', _arcMirroredFromItemId: 'item-1', [ARC_PRICE_FROZEN_KEY]: true },
55
+ quantity: 2,
56
+ termMonths: 12,
57
+ startDate: new Date('2027-01-01T00:00:00.000Z'),
58
+ endDate: null,
59
+ usageEstimates: [{ chargeCode: 'overage', estimatedQuantity: 10 }],
60
+ }
61
+
62
+ it('is false for an unchanged resubmission (markers stripped, key order differs, fields omitted)', () => {
63
+ expect(arcPricingInputsChanged(before, { configuration: { data_centre: 'ld5', port_size: '1G' } })).toBe(false)
64
+ })
65
+
66
+ it('is false when unchanged values are resubmitted explicitly', () => {
67
+ expect(
68
+ arcPricingInputsChanged(before, {
69
+ configuration: { port_size: '1G', data_centre: 'ld5' },
70
+ quantity: 2,
71
+ termMonths: 12,
72
+ startDate: '2027-01-01',
73
+ usageEstimates: [{ chargeCode: 'overage', estimatedQuantity: 10 }],
74
+ }),
75
+ ).toBe(false)
76
+ })
77
+
78
+ it('detects a configuration change', () => {
79
+ expect(arcPricingInputsChanged(before, { configuration: { port_size: '10G', data_centre: 'ld5' } })).toBe(true)
80
+ })
81
+
82
+ it('detects an added configuration key', () => {
83
+ expect(
84
+ arcPricingInputsChanged(before, { configuration: { port_size: '1G', data_centre: 'ld5', cloud_provider: 'aws' } }),
85
+ ).toBe(true)
86
+ })
87
+
88
+ it.each([
89
+ ['quantity', { quantity: 3 }],
90
+ ['termMonths', { termMonths: 24 }],
91
+ ['startDate', { startDate: '2027-02-01' }],
92
+ ['endDate', { endDate: '2028-01-01' }],
93
+ ['usageEstimates', { usageEstimates: [{ chargeCode: 'overage', estimatedQuantity: 20 }] }],
94
+ ])('detects a %s change', (_field, change) => {
95
+ expect(arcPricingInputsChanged(before, { configuration: { port_size: '1G', data_centre: 'ld5' }, ...change })).toBe(true)
96
+ })
97
+ })
98
+
99
+ describe('isFrozenAmendMirrorLine', () => {
100
+ const mirrored = { port_size: '1G', _arcMirroredFromItemId: 'item-1', [ARC_PRICE_FROZEN_KEY]: true }
101
+
102
+ it('is true only for a flagged mirror line on an Amend quote', () => {
103
+ expect(isFrozenAmendMirrorLine(mirrored, 'amend')).toBe(true)
104
+ })
105
+
106
+ it.each(['new', 'renew', 'cancel', null, undefined])('ignores the flag on a %s quote', (quoteType) => {
107
+ expect(isFrozenAmendMirrorLine(mirrored, quoteType)).toBe(false)
108
+ })
109
+
110
+ it('ignores the flag on a line that is not a mirror line', () => {
111
+ expect(isFrozenAmendMirrorLine({ port_size: '1G', [ARC_PRICE_FROZEN_KEY]: true }, 'amend')).toBe(false)
112
+ })
113
+ })
114
+
115
+ describe('stripArcMarkers', () => {
116
+ it('removes every _arc* key and keeps the rest', () => {
117
+ expect(
118
+ stripArcMarkers({ port_size: '1G', _arcMirroredFromItemId: 'item-1', [ARC_PRICE_FROZEN_KEY]: true, [ARC_REPRICED_KEY]: true }),
119
+ ).toEqual({ port_size: '1G' })
120
+ expect(stripArcMarkers(null)).toEqual({})
121
+ })
122
+ })
123
+
124
+ describe('stripQuoteOnlyArcMarkers', () => {
125
+ it('removes the freeze and re-priced flags and keeps the descriptive markers', () => {
126
+ expect(
127
+ stripQuoteOnlyArcMarkers({
128
+ port_size: '1G',
129
+ _arcMirroredFromItemId: 'item-1',
130
+ [ARC_PRICE_FROZEN_KEY]: true,
131
+ [ARC_REPRICED_KEY]: true,
132
+ }),
133
+ ).toEqual({ port_size: '1G', _arcMirroredFromItemId: 'item-1' })
134
+ })
135
+
136
+ it('returns the input untouched when there is no quote-only marker', () => {
137
+ const cfg = { port_size: '1G' }
138
+ expect(stripQuoteOnlyArcMarkers(cfg)).toBe(cfg)
139
+ expect(stripQuoteOnlyArcMarkers(null)).toBeNull()
140
+ })
141
+ })
@@ -0,0 +1,28 @@
1
+ import { withChargeWaterfall, withoutChargeWaterfall } from '../priceAdjustments/chargeWaterfall'
2
+ import type { ResolvedCharge } from '../types'
3
+
4
+ const base: ResolvedCharge = {
5
+ chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'per_unit',
6
+ unitPrice: 20, quantity: 4, totalPrice: 80, currencyCode: 'USD',
7
+ }
8
+ const rule = { ruleCode: 'volume', ruleName: 'Volume', ruleType: 'discount_percent', value: 20, unitPriceBefore: 25, unitPriceAfter: 20, delta: -5 }
9
+ it('derives pre-rule and standard money from legacy adjustments without mutating the snapshot', () => {
10
+ const input = { ...base, adjustments: [rule] }
11
+ const result = withChargeWaterfall(input)
12
+ expect(result).toMatchObject({ listUnitPrice: 25, listTotalPrice: 100, standardUnitPrice: 20, standardTotalPrice: 80 })
13
+ expect(result.adjustments?.[0].origin).toBe('rule')
14
+ expect(input.adjustments[0]).not.toHaveProperty('origin')
15
+ expect(result.unitPrice).toBe(base.unitPrice)
16
+ expect(result.totalPrice).toBe(base.totalPrice)
17
+ })
18
+ it('preserves exact tier totals and null usage totals', () => {
19
+ const tier = { ...base, unitPrice: 1 / 3, quantity: 3, totalPrice: 1 }
20
+ expect(withChargeWaterfall(tier).standardTotalPrice).toBe(1)
21
+ expect(withChargeWaterfall({ ...base, chargeType: 'usage', quantity: null, totalPrice: null })).toMatchObject({ listTotalPrice: null, standardTotalPrice: null })
22
+ })
23
+ it('keeps explicitly captured zero values', () => {
24
+ expect(withChargeWaterfall({ ...base, listUnitPrice: 0, listTotalPrice: 0 })).toMatchObject({ listUnitPrice: 0, listTotalPrice: 0 })
25
+ })
26
+ it('removes internal waterfall fields from the default public calculation response', () => {
27
+ expect(withoutChargeWaterfall(withChargeWaterfall(base))).toEqual(base)
28
+ })
@@ -1,7 +1,10 @@
1
+ import { CpqOrderLineConfiguration } from '../../data/entities'
1
2
  import { DefaultCpqOrderService, OrderError } from '../cpqOrderService'
2
3
  import type { TenantScope } from '../types'
3
4
 
4
5
  interface MockEm {
6
+ fork: jest.MockedFunction<() => MockEm>
7
+ transactional: jest.MockedFunction<(callback: (em: MockEm) => Promise<unknown>) => Promise<unknown>>
5
8
  findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>) => Promise<unknown>>
6
9
  find: jest.MockedFunction<
7
10
  (entity: unknown, where: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown[]>
@@ -24,14 +27,19 @@ interface MockNumberGenerator {
24
27
  }
25
28
 
26
29
  function createMockEm(): MockEm {
27
- return {
30
+ const em: MockEm = {
31
+ fork: jest.fn(),
32
+ transactional: jest.fn(),
28
33
  findOne: jest.fn(),
29
- find: jest.fn(),
34
+ find: jest.fn().mockResolvedValue([]),
30
35
  findAndCount: jest.fn(),
31
36
  create: jest.fn().mockImplementation((_e, data: Record<string, unknown>) => ({ ...data })),
32
37
  persist: jest.fn(),
33
38
  flush: jest.fn().mockResolvedValue(undefined),
34
39
  }
40
+ em.fork.mockReturnValue(em)
41
+ em.transactional.mockImplementation((callback) => callback(em))
42
+ return em
35
43
  }
36
44
 
37
45
  function createService(): {
@@ -601,3 +609,28 @@ describe('DefaultCpqOrderService.getOrder', () => {
601
609
  expect(result.lines[0].offeringType).toBeNull()
602
610
  })
603
611
  })
612
+
613
+
614
+ describe('conversion stored-adjustment baseline', () => {
615
+ it.each([false, true])('converts valid stored offers and refuses invalid snapshots (invalid=%s)', async (invalid) => {
616
+ const { em, service } = createService()
617
+ const charges = [{ chargeCode: 'fee', chargeType: 'mrc', pricingMethod: 'flat', quantity: 1, unitPrice: 80, totalPrice: 80,
618
+ listUnitPrice: 120, listTotalPrice: 120, standardUnitPrice: 100, standardTotalPrice: 100, currencyCode: 'USD',
619
+ adjustmentErrors: invalid ? [{ adjustmentId: 'a', quoteLineId: 'line', code: 'ABOVE_STANDARD' }] : [] }]
620
+ em.findOne.mockResolvedValueOnce({ id: 'quote', quoteId: 'sales', cpqStatus: 'accepted', currencyCode: 'USD', customerId: 'customer', pricingSummary: { nrcTotal: 0, mrcTotal: 80 } })
621
+ .mockResolvedValueOnce(null) // no existing order
622
+ em.find.mockResolvedValueOnce([{ quoteLineId: 'line', quantity: 1, action: 'add', nrcTotal: '0', mrcTotal: '80', charges,
623
+ configuration: { _arcPriceFrozen: true, _arcRepriced: true }, offeringId: null }]).mockResolvedValueOnce([])
624
+ em.create.mockImplementation((_entity, values: Record<string, unknown>) => ({ createdAt: new Date(), ...values }))
625
+ if (invalid) {
626
+ await expect(service.convertQuoteToOrder('quote', SCOPE)).rejects.toMatchObject({ status: 422, message: 'ADJUSTMENT_INVALID', details: { lineId: 'line', code: 'ABOVE_STANDARD' } })
627
+ expect(em.create).not.toHaveBeenCalled()
628
+ return
629
+ }
630
+ await service.convertQuoteToOrder('quote', SCOPE)
631
+ expect(em.create).toHaveBeenCalledWith(CpqOrderLineConfiguration, expect.objectContaining({ charges, mrcTotal: '80' }))
632
+ const orderLine = em.create.mock.calls.find(([entity]) => entity === CpqOrderLineConfiguration)?.[1]
633
+ expect(orderLine?.configuration).not.toHaveProperty('_arcPriceFrozen')
634
+ expect(orderLine?.configuration).not.toHaveProperty('_arcRepriced')
635
+ })
636
+ })
@@ -1181,3 +1181,60 @@ describe('DefaultCpqPricingService — price rules application', () => {
1181
1181
  expect(result.totalPrice).toBeNull()
1182
1182
  })
1183
1183
  })
1184
+
1185
+ /** Pin legacy money calculations before adding the list/standard waterfall. */
1186
+ describe('negotiated-pricing baseline — all rule types across charge models', () => {
1187
+ const models = ['flat', 'per_unit', 'volume', 'tiered'] as const
1188
+ const rules = [
1189
+ { type: 'discount_percent', value: 10, total: 90 },
1190
+ { type: 'discount_absolute', value: 20, total: 80 },
1191
+ { type: 'surcharge_percent', value: 10, total: 110 },
1192
+ { type: 'surcharge_absolute', value: 20, total: 120 },
1193
+ { type: 'price_override', value: 20, total: 80 },
1194
+ { type: 'discount_absolute', value: 200, total: 0 },
1195
+ { type: 'discount_percent', value: 100, total: 0 },
1196
+ ]
1197
+ it.each(models.flatMap((model) => rules.map((rule) => ({ model, ...rule }))))(
1198
+ '$model with $type $value preserves existing money and adjustment arithmetic',
1199
+ async ({ model, type, value, total }) => {
1200
+ const em = createMockEm()
1201
+ const charge = {
1202
+ ...makeCharge({ code: 'fee', name: 'Fee', chargeType: 'mrc', pricingMethod: 'fixed' }),
1203
+ chargeModel: model,
1204
+ fixedPrice: model === 'flat' ? '100' : '25',
1205
+ quantityAttributeCode: 'units',
1206
+ ...(model === 'volume' || model === 'tiered'
1207
+ ? { pricingMethod: 'table', pricingTableId: 'prices', priceColumnKey: 'rate' } : {}),
1208
+ }
1209
+ em.findOne.mockResolvedValue({ id: 'prices', dimensions: [] })
1210
+ em.find.mockImplementation(async (entity) => {
1211
+ if (entity === CpqProductCharge) return [charge]
1212
+ if (entity === CpqPricingTableEntry) return [
1213
+ { tierNumber: 0, rangeFrom: '1', rangeTo: '2', prices: { rate: 30 }, dimensionValues: {}, currencyCode: 'USD' },
1214
+ { tierNumber: 1, rangeFrom: '3', rangeTo: null, prices: { rate: model === 'tiered' ? 20 : 25 }, dimensionValues: {}, currencyCode: 'USD' },
1215
+ ]
1216
+ return [{ code: 'rule', name: 'Rule', ruleType: type, value: String(value) }]
1217
+ })
1218
+ const service = new DefaultCpqPricingService(em as unknown as never)
1219
+ const [result] = await service.resolveProductCharges({ offeringId: 'offer', configuration: { units: 4 }, currencyCode: 'USD', ...SCOPE })
1220
+ const quantity = model === 'flat' ? 1 : 4
1221
+ const expectedTotal = type === 'price_override' && model === 'flat' ? 20 : total
1222
+ expect(Math.round((result.totalPrice ?? 0) * 100)).toBe(expectedTotal * 100)
1223
+ expect(Math.round(result.unitPrice * quantity * 100)).toBe(expectedTotal * 100)
1224
+ expect(result.quantity).toBe(quantity)
1225
+ expect(result.currencyCode).toBe('USD')
1226
+ expect(result.listTotalPrice).toBe(100)
1227
+ expect(result.listUnitPrice).toBe(100 / quantity)
1228
+ expect(Math.round((result.standardTotalPrice ?? 0) * 100)).toBe(expectedTotal * 100)
1229
+ expect(result.standardUnitPrice).toBe(result.unitPrice)
1230
+ expect(result.adjustments?.[0].origin).toBe('rule')
1231
+ expect(result.adjustments).toHaveLength(1)
1232
+ expect(result.adjustments?.[0]).toMatchObject({ ruleCode: 'rule', ruleName: 'Rule', ruleType: type, value })
1233
+ expect(Math.round((result.adjustments?.[0].unitPriceBefore ?? 0) * quantity * 100)).toBe(10000)
1234
+ expect(Math.round((result.adjustments?.[0].delta ?? 0) * quantity * 100)).toBe((expectedTotal - 100) * 100)
1235
+ if (model === 'tiered') {
1236
+ expect(result.breakdown?.tiers.map((tier) => Math.round(tier.subtotal * 100))).toEqual([6000, 4000])
1237
+ }
1238
+ },
1239
+ )
1240
+ })