@dainamite/cpq 0.5.0 → 0.7.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.
- package/CHANGELOG.md +64 -0
- package/MIGRATION.md +74 -0
- package/dist/modules/cpq/acl.js +1 -0
- package/dist/modules/cpq/acl.js.map +2 -2
- package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js +45 -0
- package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js.map +7 -0
- package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js +23 -0
- package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js.map +7 -0
- package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js +19 -0
- package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js.map +7 -0
- package/dist/modules/cpq/api/orders/[orderId]/route.js +17 -1
- package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/route.js +47 -2
- package/dist/modules/cpq/api/orders/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js +54 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +42 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js +47 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js +30 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js +47 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +32 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +33 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/price/route.js +43 -3
- package/dist/modules/cpq/api/quotes/price/route.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js +3 -9
- package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js +21 -25
- package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js +48 -47
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +3 -3
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/shared.js +37 -2
- package/dist/modules/cpq/backend/cpq/inventory/shared.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +174 -184
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +168 -143
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/page.js +36 -34
- package/dist/modules/cpq/backend/cpq/offerings/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +140 -280
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/page.js +6 -6
- package/dist/modules/cpq/backend/cpq/orders/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js +41 -36
- package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js +76 -72
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/price-rules/page.js +18 -15
- package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +23 -21
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js +22 -50
- package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js +74 -52
- package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js +20 -17
- package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/page.js +1 -1
- package/dist/modules/cpq/backend/cpq/pricing/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js +115 -123
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +208 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +269 -311
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
- package/dist/modules/cpq/backend/cpq/quotes/page.js +23 -19
- package/dist/modules/cpq/backend/cpq/quotes/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js +19 -15
- package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +171 -178
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/page.js +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js +60 -50
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js +22 -18
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/wizards/page.js +1 -1
- package/dist/modules/cpq/backend/cpq/wizards/page.js.map +2 -2
- package/dist/modules/cpq/ce.js +8 -0
- package/dist/modules/cpq/ce.js.map +2 -2
- package/dist/modules/cpq/cli.js +34 -1
- package/dist/modules/cpq/cli.js.map +2 -2
- package/dist/modules/cpq/commands/price-adjustments.js +107 -0
- package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
- package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
- package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js +148 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
- package/dist/modules/cpq/components/PriceWaterfall.js +68 -0
- package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js +112 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
- package/dist/modules/cpq/components/StatusPath.js +5 -3
- package/dist/modules/cpq/components/StatusPath.js.map +2 -2
- package/dist/modules/cpq/components/useCpqRowActions.js +10 -22
- package/dist/modules/cpq/components/useCpqRowActions.js.map +2 -2
- package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
- package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
- package/dist/modules/cpq/data/entities.js +97 -0
- package/dist/modules/cpq/data/entities.js.map +2 -2
- package/dist/modules/cpq/data/price-adjustments.js +70 -0
- package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
- package/dist/modules/cpq/data/validators.js +4 -0
- package/dist/modules/cpq/data/validators.js.map +2 -2
- package/dist/modules/cpq/di.js +6 -0
- package/dist/modules/cpq/di.js.map +2 -2
- package/dist/modules/cpq/events.js +47 -0
- package/dist/modules/cpq/events.js.map +2 -2
- package/dist/modules/cpq/i18n/de.json +1220 -0
- package/dist/modules/cpq/i18n/en.json +1220 -0
- package/dist/modules/cpq/i18n/es.json +1220 -0
- package/dist/modules/cpq/i18n/pl.json +1220 -0
- package/dist/modules/cpq/index.js +1 -1
- package/dist/modules/cpq/index.js.map +2 -2
- package/dist/modules/cpq/lib/auditActor.js +10 -0
- package/dist/modules/cpq/lib/auditActor.js.map +7 -0
- package/dist/modules/cpq/lib/formatMoney.js +7 -0
- package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
- package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
- package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
- package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
- package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
- package/dist/modules/cpq/lib/quotePermissions.js +13 -0
- package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
- package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
- package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
- package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
- package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
- package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
- package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
- package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
- package/dist/modules/cpq/services/cpqOrderService.js +25 -28
- package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
- package/dist/modules/cpq/services/cpqPricingService.js +7 -0
- package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
- package/dist/modules/cpq/services/cpqQuotingService.js +330 -109
- package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
- package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
- package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
- package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
- package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
- package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
- package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
- package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
- package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
- package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
- package/dist/modules/cpq/services/types.js.map +2 -2
- package/dist/modules/cpq/setup.js +6 -0
- package/dist/modules/cpq/setup.js.map +2 -2
- package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js +39 -31
- package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js.map +2 -2
- package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js +13 -5
- package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js.map +2 -2
- package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js +41 -21
- package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js.map +2 -2
- package/dist/modules/cpq/widgets/injection-table.js +2 -2
- package/dist/modules/cpq/widgets/injection-table.js.map +2 -2
- package/dist/modules/cpq/workflows/WizardRunner.js +10 -8
- package/dist/modules/cpq/workflows/WizardRunner.js.map +2 -2
- package/dist/modules/cpq/workflows/WizardTrigger.js +15 -9
- package/dist/modules/cpq/workflows/WizardTrigger.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js +9 -7
- package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/InventorySelectStep.js +9 -4
- package/dist/modules/cpq/workflows/steps/InventorySelectStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/ItemListStep.js +16 -19
- package/dist/modules/cpq/workflows/steps/ItemListStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js +19 -32
- package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js +8 -6
- package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/ReviewStep.js +32 -30
- package/dist/modules/cpq/workflows/steps/ReviewStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/index.js +12 -0
- package/dist/modules/cpq/workflows/steps/index.js.map +2 -2
- package/package.json +1 -1
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
- package/src/modules/cpq/__tests__/i18n/dictionaries.test.ts +62 -0
- package/src/modules/cpq/__tests__/i18n/hardcodedStrings.test.ts +35 -0
- package/src/modules/cpq/__tests__/i18n/sourceScan.ts +109 -0
- package/src/modules/cpq/acl.ts +1 -0
- package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
- package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
- package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
- package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
- package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
- package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
- package/src/modules/cpq/api/orders/[orderId]/route.ts +18 -0
- package/src/modules/cpq/api/orders/route.ts +51 -1
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +48 -1
- package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +40 -1
- package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
- package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
- package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
- package/src/modules/cpq/api/quotes/price/route.ts +28 -2
- package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +3 -9
- package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +22 -26
- package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.tsx +43 -46
- package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +3 -3
- package/src/modules/cpq/backend/cpq/inventory/shared.tsx +37 -1
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +164 -120
- package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +169 -130
- package/src/modules/cpq/backend/cpq/offerings/page.tsx +34 -24
- package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +122 -308
- package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/orders/page.tsx +6 -6
- package/src/modules/cpq/backend/cpq/price-rules/PriceRuleForm.tsx +48 -34
- package/src/modules/cpq/backend/cpq/price-rules/[id]/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/price-rules/[id]/page.tsx +76 -70
- package/src/modules/cpq/backend/cpq/price-rules/page.tsx +25 -16
- package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -18
- package/src/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.tsx +29 -19
- package/src/modules/cpq/backend/cpq/pricing/[id]/page.tsx +95 -47
- package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.ts +23 -15
- package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.tsx +111 -88
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +260 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +304 -348
- package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/quotes/page.tsx +29 -21
- package/src/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.tsx +19 -15
- package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +171 -171
- package/src/modules/cpq/backend/cpq/specifications/page.tsx +2 -2
- package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.tsx +51 -41
- package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/page.tsx +17 -13
- package/src/modules/cpq/backend/cpq/wizards/page.tsx +1 -1
- package/src/modules/cpq/ce.ts +8 -0
- package/src/modules/cpq/cli.ts +48 -1
- package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
- package/src/modules/cpq/commands/price-adjustments.ts +90 -0
- package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
- package/src/modules/cpq/components/ChargeBreakdownTable.tsx +203 -0
- package/src/modules/cpq/components/PriceWaterfall.tsx +96 -0
- package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
- package/src/modules/cpq/components/StatusPath.tsx +7 -4
- package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
- package/src/modules/cpq/components/useCpqRowActions.tsx +17 -25
- package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
- package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
- package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
- package/src/modules/cpq/data/entities.ts +83 -0
- package/src/modules/cpq/data/price-adjustments.ts +67 -0
- package/src/modules/cpq/data/validators.ts +3 -0
- package/src/modules/cpq/di.ts +6 -0
- package/src/modules/cpq/events.ts +53 -2
- package/src/modules/cpq/i18n/de.json +1220 -0
- package/src/modules/cpq/i18n/en.json +1220 -0
- package/src/modules/cpq/i18n/es.json +1220 -0
- package/src/modules/cpq/i18n/pl.json +1220 -0
- package/src/modules/cpq/index.ts +1 -1
- package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
- package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
- package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
- package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
- package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
- package/src/modules/cpq/lib/auditActor.ts +13 -0
- package/src/modules/cpq/lib/formatMoney.ts +4 -0
- package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
- package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
- package/src/modules/cpq/lib/quotePermissions.ts +22 -0
- package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
- package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
- package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
- package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
- package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
- package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
- package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
- package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
- package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
- package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
- package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
- package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
- package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
- package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
- package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
- package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
- package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
- package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
- package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
- package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
- package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
- package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
- package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
- package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
- package/src/modules/cpq/services/cpqOrderService.ts +22 -28
- package/src/modules/cpq/services/cpqPricingService.ts +8 -0
- package/src/modules/cpq/services/cpqQuotingService.ts +374 -125
- package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
- package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
- package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
- package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
- package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
- package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
- package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
- package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
- package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
- package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
- package/src/modules/cpq/services/types.ts +34 -1
- package/src/modules/cpq/setup.ts +7 -1
- package/src/modules/cpq/widgets/injection/customer-inventory/widget.client.tsx +38 -31
- package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.client.tsx +7 -5
- package/src/modules/cpq/widgets/injection/quote-configurator/widget.client.tsx +36 -21
- package/src/modules/cpq/widgets/injection-table.ts +8 -2
- package/src/modules/cpq/workflows/WizardRunner.tsx +8 -5
- package/src/modules/cpq/workflows/WizardTrigger.tsx +15 -9
- package/src/modules/cpq/workflows/steps/CustomerSelectStep.tsx +11 -7
- package/src/modules/cpq/workflows/steps/InventorySelectStep.tsx +9 -5
- package/src/modules/cpq/workflows/steps/ItemListStep.tsx +18 -12
- package/src/modules/cpq/workflows/steps/OfferingSelectStep.tsx +20 -16
- package/src/modules/cpq/workflows/steps/ProductConfigureStep.tsx +8 -6
- package/src/modules/cpq/workflows/steps/ReviewStep.tsx +33 -18
- package/src/modules/cpq/workflows/steps/index.ts +12 -0
- package/src/modules/cpq/workflows/types.ts +4 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { DefaultCpqQuotingService } from '../cpqQuotingService'
|
|
3
|
+
import { CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment, CpqQuoteTargetSubscription } from '../../data/entities'
|
|
4
|
+
import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
5
|
+
|
|
6
|
+
jest.mock('../../events', () => ({ emitCpqEvent: jest.fn().mockResolvedValue(undefined) }))
|
|
7
|
+
const scope = { tenantId: randomUUID(), organizationId: randomUUID() }
|
|
8
|
+
|
|
9
|
+
it('detaching an ARC target removes its lines and their adjustments under the pricing lock', async () => {
|
|
10
|
+
const quoteId = randomUUID(), salesId = randomUUID(), lineId = randomUUID(), childId = randomUUID(), subscriptionId = randomUUID()
|
|
11
|
+
const quote = { id: quoteId, quoteId: salesId, customerId: randomUUID(), cpqStatus: 'ready', quoteType: 'amend', version: 1, pricingRevision: 4, currencyCode: 'USD', ...scope }
|
|
12
|
+
const line = { id: randomUUID(), quoteLineId: lineId, quoteConfigurationId: quoteId, offeringId: 'offer', parentLineId: null, targetSubscriptionId: subscriptionId, quantity: 1, configuration: {}, charges: [], deletedAt: null as Date | null, ...scope }
|
|
13
|
+
const child = { ...line, id: randomUUID(), quoteLineId: childId, parentLineId: lineId, targetSubscriptionId: null }
|
|
14
|
+
const adjustment = { id: randomUUID(), quoteConfigurationId: quoteId, quoteLineId: childId, deletedAt: null as Date | null, isActive: true, ...scope }
|
|
15
|
+
const target = { id: randomUUID(), quoteId, subscriptionId, quoteType: 'amend', deletedAt: null as Date | null, ...scope }
|
|
16
|
+
const salesLine = { id: lineId, deletedAt: null as Date | null, ...scope }
|
|
17
|
+
const rows = new Map<unknown, Array<Record<string, unknown>>>([
|
|
18
|
+
[CpqQuoteConfiguration, [quote]], [CpqQuoteLineConfiguration, [line, child]], [CpqQuotePriceAdjustment, [adjustment]],
|
|
19
|
+
[CpqQuoteTargetSubscription, [target]], [SalesQuote, [{ id: salesId, quoteNumber: 'Q-1', currencyCode: 'USD', ...scope }]], [SalesQuoteLine, [salesLine]],
|
|
20
|
+
])
|
|
21
|
+
const matches = (row: Record<string, unknown>, where: Record<string, unknown>) => Object.entries(where).every(([key, value]) =>
|
|
22
|
+
value === null ? row[key] == null : typeof value === 'object' ? true : row[key] === value)
|
|
23
|
+
const em = {
|
|
24
|
+
fork: jest.fn(), transactional: jest.fn(), persist: jest.fn(), flush: jest.fn().mockResolvedValue(undefined),
|
|
25
|
+
assign: jest.fn((entity: object, values: object) => Object.assign(entity, values)),
|
|
26
|
+
findOne: jest.fn(async (entity: unknown, where: Record<string, unknown>) => (rows.get(entity) ?? []).find((row) => matches(row, where)) ?? null),
|
|
27
|
+
find: jest.fn(async (entity: unknown, where: Record<string, unknown>) => (rows.get(entity) ?? []).filter((row) => matches(row, where))),
|
|
28
|
+
}
|
|
29
|
+
em.fork.mockReturnValue(em)
|
|
30
|
+
em.transactional.mockImplementation(async (execute: (tx: typeof em) => Promise<unknown>) => execute(em))
|
|
31
|
+
const service = new DefaultCpqQuotingService({ em: em as never,
|
|
32
|
+
cpqPricingService: { resolveProductCharges: async () => [] } as never,
|
|
33
|
+
cpqValidationService: { validateOfferingConfiguration: async () => ({ errors: [] }), validateRelationships: async () => [] } as never,
|
|
34
|
+
cpqProductService: {} as never, salesDocumentNumberGenerator: {} as never })
|
|
35
|
+
|
|
36
|
+
await expect(service.detachTargetSubscription(quoteId, target.id, scope)).resolves.toEqual({ ok: true })
|
|
37
|
+
expect(em.transactional).toHaveBeenCalledTimes(1)
|
|
38
|
+
expect(quote.pricingRevision).toBe(5)
|
|
39
|
+
expect(target.deletedAt).toBeInstanceOf(Date)
|
|
40
|
+
expect(line.deletedAt).toBeInstanceOf(Date)
|
|
41
|
+
expect(child.deletedAt).toBeInstanceOf(Date)
|
|
42
|
+
expect(salesLine.deletedAt).toBeInstanceOf(Date)
|
|
43
|
+
expect(adjustment).toMatchObject({ isActive: false, deletedAt: expect.any(Date) })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('refuses to detach on a submitted quote before changing anything', async () => {
|
|
47
|
+
const quote = { id: randomUUID(), quoteId: randomUUID(), cpqStatus: 'in_approval', pricingRevision: 1, ...scope }
|
|
48
|
+
const em = { fork: jest.fn(), flush: jest.fn(), find: jest.fn(), findOne: jest.fn().mockResolvedValue(quote), transactional: jest.fn() }
|
|
49
|
+
em.fork.mockReturnValue(em)
|
|
50
|
+
em.transactional.mockImplementation(async (execute: (tx: typeof em) => Promise<unknown>) => execute(em))
|
|
51
|
+
const service = new DefaultCpqQuotingService({ em: em as never, cpqPricingService: {} as never, cpqValidationService: {} as never, cpqProductService: {} as never, salesDocumentNumberGenerator: {} as never })
|
|
52
|
+
await expect(service.detachTargetSubscription(quote.id, randomUUID(), scope)).rejects.toMatchObject({ status: 409 })
|
|
53
|
+
expect(em.find).not.toHaveBeenCalled()
|
|
54
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { DefaultCpqQuotingService } from '../cpqQuotingService'
|
|
2
|
+
import { emitCpqEvent as emitImport } from '../../events'
|
|
3
|
+
import { CpqProductOffering, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
|
|
4
|
+
import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
5
|
+
|
|
6
|
+
jest.mock('../../events', () => ({ emitCpqEvent: jest.fn().mockResolvedValue(undefined) }))
|
|
7
|
+
const emit = emitImport as unknown as jest.Mock
|
|
8
|
+
const scope = { tenantId: '11111111-1111-4111-8111-111111111111', organizationId: '22222222-2222-4222-8222-222222222222' }
|
|
9
|
+
const ids = { quote: '33333333-3333-4333-8333-333333333333', sales: '44444444-4444-4444-8444-444444444444', line: '55555555-5555-4555-8555-555555555555', adjustment: '66666666-6666-4666-8666-666666666666' }
|
|
10
|
+
|
|
11
|
+
function setup(pricingSummary?: Record<string, unknown>) {
|
|
12
|
+
const quote: Record<string, unknown> = { id: ids.quote, quoteId: ids.sales, customerId: 'customer', quoteType: 'new', cpqStatus: 'new', currencyCode: 'USD', pricingRevision: 0, ...scope }
|
|
13
|
+
if (pricingSummary) quote.pricingSummary = pricingSummary
|
|
14
|
+
const line = { id: 'config', quoteLineId: ids.line, quoteConfigurationId: ids.quote, offeringId: 'offer', productId: null, parentLineId: null, quantity: 1, configuration: {}, nrcTotal: '0', mrcTotal: '100', isConfigured: true, charges: [], usageEstimates: null, ...scope }
|
|
15
|
+
const row = { id: ids.adjustment, quoteConfigurationId: ids.quote, quoteId: ids.sales, quoteLineId: ids.line, level: 'charge', chargeCode: 'fee', chargeTypeFilter: null, adjustmentType: 'discount_absolute', value: '20', currencyCode: 'USD', reasonCode: 'competitive_match', reasonNote: null as string | null, windowKind: 'relative', fromMonth: 1, toMonth: null, createdByUserId: '77777777-7777-4777-8777-777777777777', ...scope }
|
|
16
|
+
const findOne = jest.fn(async (entity: unknown) => entity === CpqQuoteConfiguration ? quote : entity === SalesQuote ? { id: ids.sales, quoteNumber: 'Q-1', currencyCode: 'USD' } : entity === CpqProductOffering ? { id: 'offer', name: 'Offering', offeringType: 'simple' } : entity === SalesQuoteLine ? { id: ids.line, totalNetAmount: '100' } : null)
|
|
17
|
+
const find = jest.fn(async (entity: unknown) => entity === CpqQuoteLineConfiguration ? [line] : entity === CpqQuotePriceAdjustment ? [row] : [])
|
|
18
|
+
const tx = { findOne, find, assign: jest.fn((entity: object, updates: object) => Object.assign(entity, updates)), flush: jest.fn().mockResolvedValue(undefined) }
|
|
19
|
+
const em = { ...tx, fork: jest.fn(), transactional: jest.fn(async (execute: (manager: typeof tx) => Promise<unknown>) => execute(tx)) }
|
|
20
|
+
em.fork.mockReturnValue(em)
|
|
21
|
+
const pricing = { resolveProductCharges: jest.fn().mockResolvedValue([{ chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'flat', unitPrice: 100, totalPrice: 100, quantity: 1, currencyCode: 'USD', adjustments: [] }]) }
|
|
22
|
+
const service = new DefaultCpqQuotingService({ em: em as never, cpqPricingService: pricing as never, cpqValidationService: { validateOfferingConfiguration: async () => ({ errors: [] }), validateRelationships: async () => [] } as never, cpqProductService: {} as never, salesDocumentNumberGenerator: {} as never })
|
|
23
|
+
return { service, quote, row }
|
|
24
|
+
}
|
|
25
|
+
const calls = (id: string) => emit.mock.calls.filter(([eventId]) => eventId === id).map(([, payload]) => payload)
|
|
26
|
+
|
|
27
|
+
beforeEach(() => emit.mockClear())
|
|
28
|
+
|
|
29
|
+
it('legacy quote: first save stores the baseline hash without a pricing signal, then a no-op save emits nothing', async () => {
|
|
30
|
+
const { service, quote } = setup({ nrcTotal: 0, mrcTotal: 80, usageCharges: [], usageTotalEstimated: null, discountTotal: 0, surchargeTotal: 0, currencyCode: 'USD' })
|
|
31
|
+
quote.cpqStatus = 'ready'
|
|
32
|
+
await service.recalculate(ids.quote, { save: true }, scope)
|
|
33
|
+
expect((quote.pricingSummary as Record<string, unknown>).snapshotHash).toEqual(expect.any(String))
|
|
34
|
+
expect(emit).not.toHaveBeenCalled()
|
|
35
|
+
await service.recalculate(ids.quote, { save: true }, scope)
|
|
36
|
+
expect(emit).not.toHaveBeenCalled()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('emits exactly one pricing_changed per save that changes prices, and an auto status_changed', async () => {
|
|
40
|
+
const { service, row } = setup({ snapshotHash: 'empty-baseline' })
|
|
41
|
+
await service.recalculate(ids.quote, { save: true }, scope)
|
|
42
|
+
expect(calls('cpq.quote.pricing_changed')).toHaveLength(1)
|
|
43
|
+
expect(calls('cpq.quote.status_changed')).toEqual([expect.objectContaining({ fromStatus: 'new', toStatus: 'ready', cause: 'auto', actorUserId: null })])
|
|
44
|
+
emit.mockClear()
|
|
45
|
+
row.value = '30'
|
|
46
|
+
await service.recalculate(ids.quote, { save: true }, scope)
|
|
47
|
+
expect(calls('cpq.quote.pricing_changed')).toEqual([expect.objectContaining({ cause: 'recalculate', dealValue: expect.objectContaining({ offer: 70 * 12 }) })])
|
|
48
|
+
expect(calls('cpq.quote.status_changed')).toHaveLength(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('reason edits do not change the hash, so no pricing signal', async () => {
|
|
52
|
+
const { service, row } = setup({ snapshotHash: 'empty-baseline' })
|
|
53
|
+
await service.recalculate(ids.quote, { save: true }, scope)
|
|
54
|
+
emit.mockClear()
|
|
55
|
+
row.reasonCode = 'other'; row.reasonNote = 'changed'
|
|
56
|
+
await service.recalculate(ids.quote, { save: true }, scope)
|
|
57
|
+
expect(emit).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('a read-only recalculation emits nothing', async () => {
|
|
61
|
+
const { service } = setup({ snapshotHash: 'empty-baseline' })
|
|
62
|
+
await service.recalculate(ids.quote, { save: false }, scope)
|
|
63
|
+
expect(emit).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('a manual transition emits status_changed with the actor', async () => {
|
|
67
|
+
const { service, quote } = setup({ snapshotHash: 'h' })
|
|
68
|
+
quote.cpqStatus = 'ready'
|
|
69
|
+
await service.transitionStatus(ids.quote, 'in_approval', scope, 'user-9')
|
|
70
|
+
expect(calls('cpq.quote.pricing_changed')).toHaveLength(0)
|
|
71
|
+
expect(calls('cpq.quote.status_changed')).toEqual([expect.objectContaining({ fromStatus: 'ready', toStatus: 'in_approval', cause: 'manual', actorUserId: 'user-9', snapshotHash: 'h' })])
|
|
72
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { emitCpqEvent as emitCpqEventImport } from '../../events'
|
|
3
|
+
import { captureQuoteEventState, emitQuoteWriteEvents } from '../priceAdjustments/quotePricingEvents'
|
|
4
|
+
import { withQuotePricingWrite } from '../priceAdjustments/quotePricingWrite'
|
|
5
|
+
|
|
6
|
+
jest.mock('../../events', () => ({ emitCpqEvent: jest.fn().mockResolvedValue(undefined) }))
|
|
7
|
+
const emitCpqEvent = emitCpqEventImport as unknown as jest.Mock
|
|
8
|
+
|
|
9
|
+
const scope = { tenantId: 'tenant', organizationId: 'org' }
|
|
10
|
+
const error = (status: number, message: string) => Object.assign(new Error(message), { status })
|
|
11
|
+
|
|
12
|
+
function quote(overrides: Record<string, unknown> = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'cfg-1', quoteId: 'sales-1', tenantId: 'tenant', organizationId: 'org', cpqStatus: 'ready', quoteType: 'new',
|
|
15
|
+
currencyCode: 'USD', pricingRevision: 3,
|
|
16
|
+
pricingSummary: {
|
|
17
|
+
snapshotHash: 'hash-a', dealValue: { list: 1200, standard: 1000, offer: 800 },
|
|
18
|
+
discountDepth: { rule: 1 / 6, manual: 0.2, total: 1 / 3, maxLineManual: 0.2 }, hasTimeBasedAdjustments: false,
|
|
19
|
+
} as Record<string, unknown>,
|
|
20
|
+
...overrides,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeHarness(locked: ReturnType<typeof quote>) {
|
|
25
|
+
const transaction = { findOne: jest.fn().mockResolvedValue(locked), flush: jest.fn().mockResolvedValue(undefined) }
|
|
26
|
+
const isolated = { transactional: jest.fn().mockImplementation(async (fn: (em: typeof transaction) => Promise<unknown>) => fn(transaction)) }
|
|
27
|
+
return { em: { fork: jest.fn().mockReturnValue(isolated) } as unknown as EntityManager, isolated }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const emitted = () => emitCpqEvent.mock.calls.map(([id]) => id)
|
|
31
|
+
|
|
32
|
+
beforeEach(() => emitCpqEvent.mockClear())
|
|
33
|
+
|
|
34
|
+
describe('emitQuoteWriteEvents', () => {
|
|
35
|
+
it('emits one pricing_changed with the pricing snapshot when the stored hash changes', async () => {
|
|
36
|
+
const q = quote()
|
|
37
|
+
const before = captureQuoteEventState({ ...q, pricingSummary: { snapshotHash: 'hash-0' } })
|
|
38
|
+
await emitQuoteWriteEvents(before, q, { pricingCause: 'adjustment' }, emitCpqEvent)
|
|
39
|
+
expect(emitCpqEvent).toHaveBeenCalledTimes(1)
|
|
40
|
+
expect(emitCpqEvent).toHaveBeenCalledWith('cpq.quote.pricing_changed', expect.objectContaining({
|
|
41
|
+
tenantId: 'tenant', organizationId: 'org', quoteId: 'cfg-1', quoteType: 'new', cpqStatus: 'ready',
|
|
42
|
+
currencyCode: 'USD', pricingRevision: 3, snapshotHash: 'hash-a',
|
|
43
|
+
dealValue: { list: 1200, standard: 1000, offer: 800 },
|
|
44
|
+
discountDepth: expect.objectContaining({ manual: 0.2 }), hasTimeBasedAdjustments: false,
|
|
45
|
+
cause: 'adjustment', occurredAt: expect.any(String),
|
|
46
|
+
}))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('emits nothing for an unchanged hash and status (no-op recalculation, reason edit)', async () => {
|
|
50
|
+
const q = quote()
|
|
51
|
+
await emitQuoteWriteEvents(captureQuoteEventState(q), q, { pricingCause: 'recalculate' }, emitCpqEvent)
|
|
52
|
+
expect(emitCpqEvent).not.toHaveBeenCalled()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('stores a legacy baseline without a pricing signal', async () => {
|
|
56
|
+
const q = quote()
|
|
57
|
+
await emitQuoteWriteEvents({ snapshotHash: null, cpqStatus: 'ready' }, q, { pricingCause: 'recalculate' }, emitCpqEvent)
|
|
58
|
+
expect(emitCpqEvent).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('emits status_changed with cause auto for a recalculation-driven transition', async () => {
|
|
62
|
+
const q = quote({ cpqStatus: 'incomplete' })
|
|
63
|
+
await emitQuoteWriteEvents({ snapshotHash: 'hash-a', cpqStatus: 'ready' }, q, { pricingCause: 'line' }, emitCpqEvent)
|
|
64
|
+
expect(emitted()).toEqual(['cpq.quote.status_changed'])
|
|
65
|
+
expect(emitCpqEvent.mock.calls[0][1]).toMatchObject({ fromStatus: 'ready', toStatus: 'incomplete', cause: 'auto', actorUserId: null })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('emits both signals and carries the actor for a manual transition', async () => {
|
|
69
|
+
const q = quote({ cpqStatus: 'in_approval' })
|
|
70
|
+
await emitQuoteWriteEvents({ snapshotHash: 'hash-0', cpqStatus: 'ready' }, q,
|
|
71
|
+
{ pricingCause: 'recalculate', statusCause: 'manual', actorUserId: 'user-1' }, emitCpqEvent)
|
|
72
|
+
expect(emitted()).toEqual(['cpq.quote.pricing_changed', 'cpq.quote.status_changed'])
|
|
73
|
+
expect(emitCpqEvent.mock.calls[1][1]).toMatchObject({ cause: 'manual', actorUserId: 'user-1', toStatus: 'in_approval' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('treats a new aggregate (clone) as a pricing change', async () => {
|
|
77
|
+
await emitQuoteWriteEvents(null, quote(), { pricingCause: 'clone' }, emitCpqEvent)
|
|
78
|
+
expect(emitted()).toEqual(['cpq.quote.pricing_changed'])
|
|
79
|
+
expect(emitCpqEvent.mock.calls[0][1]).toMatchObject({ cause: 'clone' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('logs and swallows a dispatch failure so the committed write stands', async () => {
|
|
83
|
+
const failing = jest.fn().mockRejectedValue(new Error('bus down'))
|
|
84
|
+
const log = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
85
|
+
await expect(emitQuoteWriteEvents({ snapshotHash: 'x', cpqStatus: 'new' }, quote(), { pricingCause: 'line' }, failing)).resolves.toBeUndefined()
|
|
86
|
+
expect(failing).toHaveBeenCalledTimes(2)
|
|
87
|
+
expect(log).toHaveBeenCalled()
|
|
88
|
+
log.mockRestore()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('withQuotePricingWrite events', () => {
|
|
93
|
+
it('emits after the transaction resolves, with the committed state', async () => {
|
|
94
|
+
const locked = quote({ pricingSummary: { snapshotHash: 'hash-0' } })
|
|
95
|
+
const { em, isolated } = writeHarness(locked)
|
|
96
|
+
let committed = false
|
|
97
|
+
isolated.transactional.mockImplementation(async (fn: (em: unknown) => Promise<unknown>) => {
|
|
98
|
+
const value = await fn({ findOne: jest.fn().mockResolvedValue(locked), flush: jest.fn() })
|
|
99
|
+
expect(emitCpqEvent).not.toHaveBeenCalled()
|
|
100
|
+
committed = true
|
|
101
|
+
return value
|
|
102
|
+
})
|
|
103
|
+
await withQuotePricingWrite(em, 'cfg-1', scope, async (_tx, q) => {
|
|
104
|
+
q.pricingSummary = { ...quote().pricingSummary }
|
|
105
|
+
return 'ok'
|
|
106
|
+
}, error, undefined, undefined, { pricingCause: 'adjustment' })
|
|
107
|
+
expect(committed).toBe(true)
|
|
108
|
+
expect(emitted()).toEqual(['cpq.quote.pricing_changed'])
|
|
109
|
+
expect(emitCpqEvent.mock.calls[0][1]).toMatchObject({ cause: 'adjustment', pricingRevision: 4 })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('emits nothing when the write throws (rolled back)', async () => {
|
|
113
|
+
const { em } = writeHarness(quote())
|
|
114
|
+
await expect(withQuotePricingWrite(em, 'cfg-1', scope, async (_tx, q) => {
|
|
115
|
+
q.cpqStatus = 'in_approval'
|
|
116
|
+
throw new Error('boom')
|
|
117
|
+
}, error)).rejects.toThrow('boom')
|
|
118
|
+
expect(emitCpqEvent).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('emits nothing for a refused stale revision', async () => {
|
|
122
|
+
const { em } = writeHarness(quote())
|
|
123
|
+
await expect(withQuotePricingWrite(em, 'cfg-1', scope, async () => 'never', error, 1)).rejects.toMatchObject({ status: 409 })
|
|
124
|
+
expect(emitCpqEvent).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import { MikroORM, type EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy'
|
|
4
|
+
import { CpqQuoteConfiguration, CpqQuoteLineConfiguration } from '../../data/entities'
|
|
5
|
+
import { withQuotePricingWrite } from '../priceAdjustments/quotePricingWrite'
|
|
6
|
+
|
|
7
|
+
/** Real identity maps, no database: only transaction SQL/reads are stubbed. */
|
|
8
|
+
it.each([false, true])('keeps preloaded parent entities and pending writes isolated (failure=%s)', async (fail) => {
|
|
9
|
+
const orm = await MikroORM.init({ entities: [CpqQuoteConfiguration, CpqQuoteLineConfiguration],
|
|
10
|
+
dbName: 'cpq_isolation_probe', metadataProvider: ReflectMetadataProvider })
|
|
11
|
+
const parent = orm.em.fork({ clear: true })
|
|
12
|
+
const scope = { tenantId: '11111111-1111-4111-8111-111111111111', organizationId: '22222222-2222-4222-8222-222222222222' }
|
|
13
|
+
const quoteId = '33333333-3333-4333-8333-333333333333'
|
|
14
|
+
const lineId = '44444444-4444-4444-8444-444444444444'
|
|
15
|
+
const quote = parent.create(CpqQuoteConfiguration, { id: quoteId, quoteId, customerId: quoteId, ...scope, pricingRevision: 4 })
|
|
16
|
+
const line = parent.create(CpqQuoteLineConfiguration, { id: lineId, quoteLineId: lineId, quoteConfigurationId: quoteId, ...scope, mrcTotal: '100' })
|
|
17
|
+
parent.persist([quote, line])
|
|
18
|
+
const originalFork = parent.fork.bind(parent)
|
|
19
|
+
let isolated: EntityManager | undefined
|
|
20
|
+
jest.spyOn(parent, 'fork').mockImplementation((options) => {
|
|
21
|
+
const fork = originalFork(options)
|
|
22
|
+
isolated = fork
|
|
23
|
+
expect(fork.getUnitOfWork().getIdentityMap().keys()).toEqual([])
|
|
24
|
+
expect(fork.getUnitOfWork().getPersistStack().size).toBe(0)
|
|
25
|
+
// Don't run SQL: execute against this real, newly forked identity map.
|
|
26
|
+
jest.spyOn(fork, 'transactional').mockImplementation(async (execute) => execute(fork))
|
|
27
|
+
jest.spyOn(fork, 'findOne').mockImplementation(async () => {
|
|
28
|
+
const loaded = fork.getReference(CpqQuoteConfiguration, quoteId)
|
|
29
|
+
Object.assign(loaded, { ...scope, quoteId, pricingRevision: 4 })
|
|
30
|
+
return loaded as never
|
|
31
|
+
})
|
|
32
|
+
jest.spyOn(fork, 'flush').mockResolvedValue(undefined)
|
|
33
|
+
return fork
|
|
34
|
+
})
|
|
35
|
+
try {
|
|
36
|
+
const operation = withQuotePricingWrite(parent, quoteId, scope, async (transaction, locked) => {
|
|
37
|
+
expect(transaction).toBe(isolated)
|
|
38
|
+
expect(locked).not.toBe(quote)
|
|
39
|
+
const loadedLine = transaction.getReference(CpqQuoteLineConfiguration, lineId)
|
|
40
|
+
expect(loadedLine).not.toBe(line)
|
|
41
|
+
loadedLine.mrcTotal = '80'
|
|
42
|
+
if (fail) throw new Error('Rejected after repricing')
|
|
43
|
+
return locked.pricingRevision
|
|
44
|
+
}, (status, message) => Object.assign(new Error(message), { status }))
|
|
45
|
+
if (fail) await expect(operation).rejects.toThrow('Rejected after repricing')
|
|
46
|
+
else expect(await operation).toBe(5)
|
|
47
|
+
expect(quote.pricingRevision).toBe(4)
|
|
48
|
+
expect(line.mrcTotal).toBe('100')
|
|
49
|
+
expect(parent.getUnitOfWork().getPersistStack().has(quote)).toBe(true)
|
|
50
|
+
expect(parent.getUnitOfWork().getPersistStack().has(line)).toBe(true)
|
|
51
|
+
} finally { await orm.close(true) }
|
|
52
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { LockMode } from '@mikro-orm/core'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { CpqQuoteConfiguration } from '../../data/entities'
|
|
4
|
+
import { withQuotePricingWrite } from '../priceAdjustments/quotePricingWrite'
|
|
5
|
+
import { QuotingError } from '../cpqQuotingService'
|
|
6
|
+
const scope = { tenantId: 'tenant', organizationId: 'org' }
|
|
7
|
+
const error = (status: number, message: string) => new QuotingError(status, message)
|
|
8
|
+
function setup() {
|
|
9
|
+
const quote = { id: 'quote', quoteId: 'sales', pricingRevision: 4 }
|
|
10
|
+
const transaction = { findOne: jest.fn().mockResolvedValue(quote), flush: jest.fn().mockResolvedValue(undefined) }
|
|
11
|
+
const isolated = { transactional: jest.fn().mockImplementation(async (fn: (em: typeof transaction) => Promise<unknown>) => fn(transaction)) }
|
|
12
|
+
const root = { fork: jest.fn().mockReturnValue(isolated), transactional: jest.fn(), findOne: jest.fn(), flush: jest.fn() }
|
|
13
|
+
return { quote, transaction, root, isolated, em: root as unknown as EntityManager }
|
|
14
|
+
}
|
|
15
|
+
it('locks and refreshes the tenant-scoped root and passes only the transaction EM to the write', async () => {
|
|
16
|
+
const { quote, transaction, root, isolated, em } = setup()
|
|
17
|
+
const result = await withQuotePricingWrite(em, 'quote', scope, async (tx, locked) => {
|
|
18
|
+
expect(tx).toBe(transaction)
|
|
19
|
+
expect(locked).toBe(quote)
|
|
20
|
+
expect(locked.pricingRevision).toBe(5)
|
|
21
|
+
await tx.flush()
|
|
22
|
+
return 'saved'
|
|
23
|
+
}, error, 4)
|
|
24
|
+
expect(result).toBe('saved')
|
|
25
|
+
expect(root.fork).toHaveBeenCalledWith({ clear: true, useContext: false })
|
|
26
|
+
expect(root.transactional).not.toHaveBeenCalled()
|
|
27
|
+
expect(isolated.transactional).toHaveBeenCalledWith(expect.any(Function), { clear: true })
|
|
28
|
+
expect(transaction.findOne).toHaveBeenCalledWith(CpqQuoteConfiguration,
|
|
29
|
+
{ id: 'quote', tenantId: 'tenant', organizationId: 'org', deletedAt: null },
|
|
30
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true })
|
|
31
|
+
expect(root.findOne).not.toHaveBeenCalled()
|
|
32
|
+
expect(root.flush).not.toHaveBeenCalled()
|
|
33
|
+
expect(quote.pricingRevision).toBe(5)
|
|
34
|
+
})
|
|
35
|
+
it('refuses a stale revision before calling the mutation or flushing', async () => {
|
|
36
|
+
const { quote, transaction, em } = setup()
|
|
37
|
+
const write = jest.fn()
|
|
38
|
+
await expect(withQuotePricingWrite(em, 'quote', scope, write, error, 3)).rejects.toMatchObject({ status: 409, message: 'STALE_REVISION' })
|
|
39
|
+
expect(write).not.toHaveBeenCalled()
|
|
40
|
+
expect(transaction.flush).not.toHaveBeenCalled()
|
|
41
|
+
expect(quote.pricingRevision).toBe(4)
|
|
42
|
+
})
|
|
43
|
+
it('locks the sales quote alias with the same scope when the CPQ id lookup misses', async () => {
|
|
44
|
+
const { quote, transaction, em } = setup()
|
|
45
|
+
transaction.findOne.mockResolvedValueOnce(null)
|
|
46
|
+
await withQuotePricingWrite(em, 'sales', scope, async () => 'saved', error)
|
|
47
|
+
expect(transaction.findOne).toHaveBeenLastCalledWith(CpqQuoteConfiguration,
|
|
48
|
+
{ quoteId: 'sales', ...scope, deletedAt: null }, { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true })
|
|
49
|
+
expect(quote.pricingRevision).toBe(5)
|
|
50
|
+
})
|
|
51
|
+
it('does not execute an out-of-scope or missing quote', async () => {
|
|
52
|
+
const { transaction, em } = setup()
|
|
53
|
+
transaction.findOne.mockResolvedValue(null)
|
|
54
|
+
const write = jest.fn()
|
|
55
|
+
await expect(withQuotePricingWrite(em, 'missing', scope, write, error)).rejects.toMatchObject({ status: 404 })
|
|
56
|
+
expect(write).not.toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
it('propagates a write failure out of transactional so the database rolls back', async () => {
|
|
59
|
+
const { transaction, isolated, em } = setup()
|
|
60
|
+
const failure = new Error('Failed during reprice')
|
|
61
|
+
await expect(withQuotePricingWrite(em, 'quote', scope, async () => { throw failure }, error)).rejects.toBe(failure)
|
|
62
|
+
expect(isolated.transactional).toHaveBeenCalledTimes(1)
|
|
63
|
+
// The revision is flushed before a dependent query can reset its change set;
|
|
64
|
+
// the thrown error still rolls this SQL back in the real transaction.
|
|
65
|
+
expect(transaction.flush).toHaveBeenCalledTimes(1)
|
|
66
|
+
})
|
|
67
|
+
it('refuses to run inside an ambient transaction instead of deadlocking on its own lock', async () => {
|
|
68
|
+
const { root, em } = setup()
|
|
69
|
+
Object.assign(root, { isInTransaction: () => true })
|
|
70
|
+
const write = jest.fn()
|
|
71
|
+
await expect(withQuotePricingWrite(em, 'quote', scope, write, error)).rejects.toThrow('must not run inside an existing transaction')
|
|
72
|
+
expect(root.fork).not.toHaveBeenCalled()
|
|
73
|
+
expect(write).not.toHaveBeenCalled()
|
|
74
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { resolveManualAdjustments, type AdjustmentPricingLine } from '../priceAdjustments/resolveManualAdjustments'
|
|
2
|
+
import type { PriceAdjustment } from '../../data/price-adjustments'
|
|
3
|
+
import type { ResolvedCharge } from '../types'
|
|
4
|
+
|
|
5
|
+
const charge: ResolvedCharge = { chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'per_unit', unitPrice: 25, totalPrice: 100, quantity: 4, currencyCode: 'USD' }
|
|
6
|
+
const line: AdjustmentPricingLine = { lineId: 'line', parentLineId: null, enginePriced: true, mirrored: false, charges: [charge] }
|
|
7
|
+
const row: PriceAdjustment = { id: 'adj', quoteLineId: 'line', level: 'charge', chargeCode: 'fee', adjustmentType: 'discount_percent', value: 20, reasonCode: 'competitive_match' }
|
|
8
|
+
function price(rows: PriceAdjustment[], lines = [line]) { return resolveManualAdjustments(lines, rows, 'USD') }
|
|
9
|
+
|
|
10
|
+
it.each([
|
|
11
|
+
{ adjustmentType: 'discount_percent' as const, value: 20, currencyCode: undefined, cents: 8000 },
|
|
12
|
+
{ adjustmentType: 'discount_absolute' as const, value: 20, currencyCode: 'USD', cents: 8000 },
|
|
13
|
+
{ adjustmentType: 'discount_absolute' as const, value: 200, currencyCode: 'USD', cents: 0 },
|
|
14
|
+
{ adjustmentType: 'price_override' as const, value: 20, currencyCode: 'USD', cents: 8000 },
|
|
15
|
+
{ adjustmentType: 'price_override' as const, value: 0, currencyCode: 'USD', cents: 0 },
|
|
16
|
+
])('applies $adjustmentType $value against standard, per charge instance', ({ cents, ...values }) => {
|
|
17
|
+
const result = price([{ ...row, ...values }])
|
|
18
|
+
expect(result.errors).toEqual([])
|
|
19
|
+
const priced = result.lines[0].charges[0]
|
|
20
|
+
expect(Math.round(priced.totalPrice! * 100)).toBe(cents)
|
|
21
|
+
expect(Math.round(priced.unitPrice * 4 * 100)).toBe(cents)
|
|
22
|
+
expect(priced).toMatchObject({ listTotalPrice: 100, standardTotalPrice: 100 })
|
|
23
|
+
expect(priced.adjustments?.[0]).toMatchObject({ origin: 'manual', adjustmentId: 'adj', unitPriceBefore: 25 })
|
|
24
|
+
expect(charge).not.toHaveProperty('adjustments')
|
|
25
|
+
})
|
|
26
|
+
it('uses charge above line above nearest ancestor, without compounding', () => {
|
|
27
|
+
const rows: PriceAdjustment[] = [
|
|
28
|
+
{ ...row, id: 'root', quoteLineId: 'root', level: 'line', chargeCode: undefined, value: 50 },
|
|
29
|
+
{ ...row, id: 'parent', quoteLineId: 'parent', level: 'line', chargeCode: undefined, value: 30 },
|
|
30
|
+
{ ...row, id: 'line', level: 'line', chargeCode: undefined, value: 10 }, row,
|
|
31
|
+
]
|
|
32
|
+
const lines = [{ ...line, lineId: 'root' }, { ...line, lineId: 'parent', parentLineId: 'root' }, { ...line, parentLineId: 'parent' }]
|
|
33
|
+
expect(price(rows, lines).lines.map((item) => item.charges[0].totalPrice)).toEqual([50, 70, 80])
|
|
34
|
+
expect(price(rows.slice(0, -1), lines).lines[2].charges[0].totalPrice).toBe(90)
|
|
35
|
+
expect(price(rows.slice(0, -2), lines).lines[2].charges[0].totalPrice).toBe(70)
|
|
36
|
+
})
|
|
37
|
+
it('skips an invalid charge override and falls back to a valid line discount', () => {
|
|
38
|
+
const result = price([{ ...row, adjustmentType: 'price_override', currencyCode: 'USD', value: 30 }, { ...row, id: 'line-discount', level: 'line', chargeCode: undefined, value: 10 }])
|
|
39
|
+
expect(result.errors).toEqual([expect.objectContaining({ code: 'ABOVE_STANDARD' })])
|
|
40
|
+
expect(result.lines[0].charges[0].totalPrice).toBe(90)
|
|
41
|
+
})
|
|
42
|
+
it.each([
|
|
43
|
+
[{ chargeCode: 'missing' }, {}, 'ADJUSTMENT_TARGET_MISSING'],
|
|
44
|
+
[{ quoteLineId: 'missing' }, {}, 'ADJUSTMENT_TARGET_MISSING'],
|
|
45
|
+
[{ currencyCode: 'EUR' }, {}, 'CURRENCY_MISMATCH'],
|
|
46
|
+
[{ adjustmentType: 'discount_absolute', currencyCode: undefined }, {}, 'CURRENCY_MISMATCH'],
|
|
47
|
+
[{ adjustmentType: 'price_override', currencyCode: 'USD' }, { pricingMethod: 'tiered' }, 'TYPE_NOT_ALLOWED'],
|
|
48
|
+
[{ adjustmentType: 'price_override', currencyCode: 'USD' }, { pricingMethod: 'volume' }, 'TYPE_NOT_ALLOWED'],
|
|
49
|
+
[{ value: 101 }, {}, 'TYPE_NOT_ALLOWED'],
|
|
50
|
+
[{}, { chargeType: 'usage', totalPrice: null, quantity: null }, 'TYPE_NOT_ALLOWED'],
|
|
51
|
+
] as const)('keeps and flags invalid adjustments (%s)', (patch, chargePatch, code) => {
|
|
52
|
+
const result = price([{ ...row, ...patch }], [{ ...line, charges: [{ ...charge, ...chargePatch }] }])
|
|
53
|
+
expect(result.errors).toEqual([expect.objectContaining({ code })])
|
|
54
|
+
expect(result.lines[0].charges[0].unitPrice).toBe(charge.unitPrice)
|
|
55
|
+
})
|
|
56
|
+
it('rejects ambiguous charge codes and overlapping same-level discounts', () => {
|
|
57
|
+
expect(price([row], [{ ...line, charges: [charge, charge] }]).errors[0].code).toBe('TARGET_AMBIGUOUS')
|
|
58
|
+
const result = price([row, { ...row, id: 'second' }])
|
|
59
|
+
expect(result.errors).toHaveLength(2)
|
|
60
|
+
expect(result.errors.every((error) => error.code === 'ADJUSTMENT_OVERLAP')).toBe(true)
|
|
61
|
+
expect(result.lines[0].charges[0].totalPrice).toBe(100)
|
|
62
|
+
})
|
|
63
|
+
it('never changes mirror/free-form offers and rejects adjustments on them', () => {
|
|
64
|
+
for (const patch of [{ mirrored: true }, { enginePriced: false }]) {
|
|
65
|
+
const contracted = { ...charge, unitPrice: 20, totalPrice: 80, standardUnitPrice: 25, standardTotalPrice: 100 }
|
|
66
|
+
const result = price([row], [{ ...line, ...patch, charges: [contracted] }])
|
|
67
|
+
expect(result.errors[0].code).toBe('LINE_NOT_ELIGIBLE')
|
|
68
|
+
expect(result.lines[0].charges[0].totalPrice).toBe(80)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
it('filters line discounts by charge type and never discounts usage', () => {
|
|
72
|
+
const result = price([{ ...row, level: 'line', chargeCode: undefined, chargeTypeFilter: 'mrc' }], [{ ...line, charges: [charge, { ...charge, chargeCode: 'setup', chargeType: 'nrc' }, { ...charge, chargeCode: 'usage', chargeType: 'usage', totalPrice: null, quantity: null }] }])
|
|
73
|
+
expect(result.lines[0].charges.map((item) => item.totalPrice)).toEqual([80, 100, null])
|
|
74
|
+
expect(result.errors).toEqual([])
|
|
75
|
+
})
|
|
76
|
+
it('is idempotent on previously adjusted charges and safely traverses a malformed parent cycle', () => {
|
|
77
|
+
const first = price([row])
|
|
78
|
+
expect(price([row], first.lines)).toEqual(first)
|
|
79
|
+
expect(price([{ ...row, level: 'line', chargeCode: undefined }], [{ ...line, parentLineId: 'line' }]).lines[0].charges[0].totalPrice).toBe(80)
|
|
80
|
+
})
|
|
81
|
+
it('handles zero charge quantities without division by zero', () => {
|
|
82
|
+
const result = price([{ ...row, adjustmentType: 'discount_absolute', currencyCode: 'USD' }], [{ ...line, charges: [{ ...charge, quantity: 0, totalPrice: 0 }] }])
|
|
83
|
+
expect(result.lines[0].charges[0].unitPrice).toBe(0)
|
|
84
|
+
expect(result.lines[0].charges[0].totalPrice).toBe(0)
|
|
85
|
+
})
|
|
86
|
+
it('skips an invalid same-level override without treating it as an overlap with a valid discount', () => {
|
|
87
|
+
const result = price([row, { ...row, id: 'invalid', adjustmentType: 'price_override', currencyCode: 'USD', value: 200 }])
|
|
88
|
+
expect(result.errors).toEqual([expect.objectContaining({ adjustmentId: 'invalid', code: 'ABOVE_STANDARD' })])
|
|
89
|
+
expect(result.lines[0].charges[0].totalPrice).toBe(80)
|
|
90
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { storedAdjustmentFailure } from '../priceAdjustments/storedAdjustmentFailure'
|
|
2
|
+
it('refuses missing charge and orphaned line targets recorded in the stored snapshot', () => {
|
|
3
|
+
expect(storedAdjustmentFailure({} as never, [{ quoteLineId: 'line', charges: [], validationErrors: [{ message: 'ADJUSTMENT_TARGET_MISSING' }] }] as never)).toEqual({ lineId: 'line', code: 'ADJUSTMENT_TARGET_MISSING' })
|
|
4
|
+
expect(storedAdjustmentFailure({ validationResult: { errors: [{ lineId: 'removed', message: 'ADJUSTMENT_TARGET_MISSING' }] } } as never, [])).toEqual({ lineId: 'removed', code: 'ADJUSTMENT_TARGET_MISSING' })
|
|
5
|
+
})
|
|
6
|
+
it('fails closed on malformed charge error flags without changing legacy non-adjustment validation', () => {
|
|
7
|
+
expect(storedAdjustmentFailure({} as never, [{ quoteLineId: 'line', charges: [{ adjustmentErrors: [{}] }] }] as never)).toEqual({ lineId: 'line', code: 'ADJUSTMENT_INVALID' })
|
|
8
|
+
expect(storedAdjustmentFailure({} as never, [{ quoteLineId: 'line', validationErrors: [{ message: 'Required attribute missing' }] }] as never)).toBe(null)
|
|
9
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removing an existing subscription item on an Amend / Renew quote.
|
|
3
|
+
*
|
|
4
|
+
* An ARC quote mirrors each live subscription item as a quote line. Activation
|
|
5
|
+
* only acts on explicit line actions — an item with no line is left untouched —
|
|
6
|
+
* so deleting a mirror line used to keep the item active on the subscription.
|
|
7
|
+
* Instead, "remove" turns the mirror line (and its mirrored components) into a
|
|
8
|
+
* `cancel` line, which activation applies by terminating the item. Lines this
|
|
9
|
+
* quote added under it are dropped. A cancelled line can be restored from the
|
|
10
|
+
* subscription item it mirrors.
|
|
11
|
+
*
|
|
12
|
+
* Cancel quotes are excluded: every line there is already a cancel line.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isArcMirrorLine } from './arcPriceFreeze'
|
|
16
|
+
|
|
17
|
+
export type ArcRemovalLine = {
|
|
18
|
+
quoteLineId: string
|
|
19
|
+
parentLineId?: string | null
|
|
20
|
+
action: string
|
|
21
|
+
configuration: Record<string, unknown> | null | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ITEM_REMOVAL_QUOTE_TYPES = ['amend', 'renew'] as const
|
|
25
|
+
|
|
26
|
+
function supportsItemRemoval(quoteType: string | null | undefined): boolean {
|
|
27
|
+
return (ITEM_REMOVAL_QUOTE_TYPES as readonly string[]).includes(quoteType ?? '')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether removing this line cancels a subscription item instead of deleting the line. */
|
|
31
|
+
export function removalCancelsItem(line: Pick<ArcRemovalLine, 'configuration'>, quoteType: string | null | undefined): boolean {
|
|
32
|
+
return supportsItemRemoval(quoteType) && isArcMirrorLine(line.configuration, quoteType)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A subscription item marked for removal on an Amend / Renew quote. It is not priced. */
|
|
36
|
+
export function isArcCancelledLine(line: Pick<ArcRemovalLine, 'action'>, quoteType: string | null | undefined): boolean {
|
|
37
|
+
return line.action === 'cancel' && supportsItemRemoval(quoteType)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The line and every descendant, root first. */
|
|
41
|
+
export function collectLineSubtree<T extends ArcRemovalLine>(root: T, lines: readonly T[]): T[] {
|
|
42
|
+
const children = new Map<string, T[]>()
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (!line.parentLineId) continue
|
|
45
|
+
const siblings = children.get(line.parentLineId) ?? []
|
|
46
|
+
siblings.push(line)
|
|
47
|
+
children.set(line.parentLineId, siblings)
|
|
48
|
+
}
|
|
49
|
+
const out: T[] = []
|
|
50
|
+
const seen = new Set<string>()
|
|
51
|
+
const queue: T[] = [root]
|
|
52
|
+
for (let index = 0; index < queue.length; index++) {
|
|
53
|
+
const line = queue[index]
|
|
54
|
+
if (seen.has(line.quoteLineId)) continue
|
|
55
|
+
seen.add(line.quoteLineId)
|
|
56
|
+
out.push(line)
|
|
57
|
+
queue.push(...(children.get(line.quoteLineId) ?? []))
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ArcRemovalPlan<T> = {
|
|
63
|
+
/** Mirror lines that become `cancel` lines. */
|
|
64
|
+
cancel: T[]
|
|
65
|
+
/** Roots of quote-added subtrees to delete (each deleted with its descendants). */
|
|
66
|
+
removeRoots: T[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function planArcLineRemoval<T extends ArcRemovalLine>(root: T, lines: readonly T[], quoteType: string | null | undefined): ArcRemovalPlan<T> {
|
|
70
|
+
const subtree = collectLineSubtree(root, lines)
|
|
71
|
+
const cancel = subtree.filter((line) => isArcMirrorLine(line.configuration, quoteType) && line.action !== 'cancel')
|
|
72
|
+
const remove = subtree.filter((line) => !isArcMirrorLine(line.configuration, quoteType))
|
|
73
|
+
const removeIds = new Set(remove.map((line) => line.quoteLineId))
|
|
74
|
+
const removeRoots = remove.filter((line) => !line.parentLineId || !removeIds.has(line.parentLineId))
|
|
75
|
+
return { cancel, removeRoots }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ArcRestorePlan<T> = { ok: true; restore: T[] } | { ok: false; reason: 'parent_cancelled' }
|
|
79
|
+
|
|
80
|
+
/** Restore the line and its cancelled mirrored components; blocked while its parent stays cancelled. */
|
|
81
|
+
export function planArcLineRestore<T extends ArcRemovalLine>(root: T, lines: readonly T[], quoteType: string | null | undefined): ArcRestorePlan<T> {
|
|
82
|
+
const parent = root.parentLineId ? lines.find((line) => line.quoteLineId === root.parentLineId) : undefined
|
|
83
|
+
if (parent && isArcCancelledLine(parent, quoteType)) return { ok: false, reason: 'parent_cancelled' }
|
|
84
|
+
const restore = collectLineSubtree(root, lines)
|
|
85
|
+
.filter((line) => isArcCancelledLine(line, quoteType) && isArcMirrorLine(line.configuration, quoteType))
|
|
86
|
+
return { ok: true, restore }
|
|
87
|
+
}
|