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