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