@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
|
@@ -4,6 +4,9 @@ import { resolveCpqRouteContext } from '../../../../context'
|
|
|
4
4
|
import { cpqUpdateQuoteItemSchema } from '../../../../../data/validators'
|
|
5
5
|
import { isQuotingError } from '../../../../../services/cpqQuotingService'
|
|
6
6
|
import { resolveQuotingService } from '../../../../resolveQuotingService'
|
|
7
|
+
import { CPQ_QUOTE_LINE_RESOURCE_KIND, runCpqMutationGuards } from '../../../../_helpers/quoteMutationGuard'
|
|
8
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
|
+
import { CPQ_GUARDED_WRITE_ERRORS } from '../../../../_helpers/quoteRouteOpenApi'
|
|
7
10
|
|
|
8
11
|
export const metadata = {
|
|
9
12
|
PUT: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
|
|
@@ -19,8 +22,20 @@ export async function PUT(req: Request, { params }: { params: Promise<{ quoteId:
|
|
|
19
22
|
const body = cpqUpdateQuoteItemSchema.parse(await req.json())
|
|
20
23
|
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
21
24
|
|
|
25
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
26
|
+
resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
|
|
27
|
+
resourceId: lineId,
|
|
28
|
+
operation: 'update',
|
|
29
|
+
mutationPayload: { quoteId, ...body },
|
|
30
|
+
})
|
|
31
|
+
if (!guard.ok) return guard.response
|
|
32
|
+
const effective = guard.modifiedPayload
|
|
33
|
+
? cpqUpdateQuoteItemSchema.parse({ ...body, ...guard.modifiedPayload })
|
|
34
|
+
: body
|
|
35
|
+
|
|
22
36
|
const service = resolveQuotingService(ctx)
|
|
23
|
-
const result = await service.updateQuoteItem(quoteId, lineId,
|
|
37
|
+
const result = await service.updateQuoteItem(quoteId, lineId, effective, scope)
|
|
38
|
+
await guard.runAfterSuccess()
|
|
24
39
|
return NextResponse.json(result)
|
|
25
40
|
} catch (err) {
|
|
26
41
|
if (err instanceof z.ZodError) {
|
|
@@ -42,8 +57,17 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ quote
|
|
|
42
57
|
const { quoteId, lineId } = await params
|
|
43
58
|
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
44
59
|
|
|
60
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
61
|
+
resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
|
|
62
|
+
resourceId: lineId,
|
|
63
|
+
operation: 'delete',
|
|
64
|
+
mutationPayload: { quoteId },
|
|
65
|
+
})
|
|
66
|
+
if (!guard.ok) return guard.response
|
|
67
|
+
|
|
45
68
|
const service = resolveQuotingService(ctx)
|
|
46
69
|
const result = await service.removeQuoteItem(quoteId, lineId, scope)
|
|
70
|
+
await guard.runAfterSuccess()
|
|
47
71
|
return NextResponse.json(result)
|
|
48
72
|
} catch (err) {
|
|
49
73
|
if (isQuotingError(err)) {
|
|
@@ -53,3 +77,26 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ quote
|
|
|
53
77
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
54
78
|
}
|
|
55
79
|
}
|
|
80
|
+
|
|
81
|
+
export const openApi: OpenApiRouteDoc = {
|
|
82
|
+
tag: 'CPQ Quotes',
|
|
83
|
+
summary: 'CPQ quote line',
|
|
84
|
+
pathParams: z.object({ quoteId: z.string().uuid(), lineId: z.string().uuid() }),
|
|
85
|
+
methods: {
|
|
86
|
+
PUT: {
|
|
87
|
+
summary: 'Update a quote line (configuration, quantity, term)',
|
|
88
|
+
description:
|
|
89
|
+
'Re-prices the line unless it is a frozen Amend mirror line whose pricing inputs are unchanged. Mutation guards run first (resource kind `cpq.quote_line`, update). `_arc*` configuration keys are server-owned and ignored.',
|
|
90
|
+
requestBody: { schema: cpqUpdateQuoteItemSchema },
|
|
91
|
+
responses: [{ status: 200, description: 'Quote after the update' }],
|
|
92
|
+
errors: CPQ_GUARDED_WRITE_ERRORS,
|
|
93
|
+
},
|
|
94
|
+
DELETE: {
|
|
95
|
+
summary: 'Remove a quote line',
|
|
96
|
+
description:
|
|
97
|
+
'On an Amend / Renew quote, a line mirroring a live subscription item is not deleted: it (and its mirrored components) becomes a `cancel` line with zero totals, and activation terminates the item; lines this quote added under it are deleted. 409 when the item is already marked for removal — use `POST .../restore` to undo. Other lines are deleted with their descendants. Mutation guards run first (resource kind `cpq.quote_line`, delete).',
|
|
98
|
+
responses: [{ status: 200, description: 'Quote after the removal' }],
|
|
99
|
+
errors: CPQ_GUARDED_WRITE_ERRORS.filter((doc) => doc.status !== 400),
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}
|
|
@@ -3,6 +3,9 @@ import { resolveCpqRouteContext } from '../../../context'
|
|
|
3
3
|
import { cpqAddQuoteItemSchema } from '../../../../data/validators'
|
|
4
4
|
import { resolveQuotingService } from '../../../resolveQuotingService'
|
|
5
5
|
import { handleArcError } from '../../../_helpers/handleArcError'
|
|
6
|
+
import { CPQ_QUOTE_LINE_RESOURCE_KIND, runCpqMutationGuards } from '../../../_helpers/quoteMutationGuard'
|
|
7
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
8
|
+
import { CPQ_GUARDED_WRITE_ERRORS, cpqQuoteIdPathParams } from '../../../_helpers/quoteRouteOpenApi'
|
|
6
9
|
|
|
7
10
|
export const metadata = {
|
|
8
11
|
POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
|
|
@@ -17,10 +20,37 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
|
|
|
17
20
|
const body = cpqAddQuoteItemSchema.parse(await req.json())
|
|
18
21
|
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
19
22
|
|
|
23
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
24
|
+
resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
|
|
25
|
+
operation: 'create',
|
|
26
|
+
mutationPayload: { quoteId, ...body },
|
|
27
|
+
})
|
|
28
|
+
if (!guard.ok) return guard.response
|
|
29
|
+
const effective = guard.modifiedPayload
|
|
30
|
+
? cpqAddQuoteItemSchema.parse({ ...body, ...guard.modifiedPayload })
|
|
31
|
+
: body
|
|
32
|
+
|
|
20
33
|
const service = resolveQuotingService(ctx)
|
|
21
|
-
const result = await service.addQuoteItem(quoteId,
|
|
34
|
+
const result = await service.addQuoteItem(quoteId, effective, scope)
|
|
35
|
+
await guard.runAfterSuccess()
|
|
22
36
|
return NextResponse.json(result, { status: 201 })
|
|
23
37
|
} catch (err) {
|
|
24
38
|
return handleArcError(err, 'cpq/quotes/[quoteId]/items.POST')
|
|
25
39
|
}
|
|
26
40
|
}
|
|
41
|
+
|
|
42
|
+
export const openApi: OpenApiRouteDoc = {
|
|
43
|
+
tag: 'CPQ Quotes',
|
|
44
|
+
summary: 'CPQ quote lines',
|
|
45
|
+
pathParams: cpqQuoteIdPathParams,
|
|
46
|
+
methods: {
|
|
47
|
+
POST: {
|
|
48
|
+
summary: 'Add a configured line to a CPQ quote',
|
|
49
|
+
description:
|
|
50
|
+
'Prices the line and recalculates the quote. Mutation guards run first (resource kind `cpq.quote_line`, create). `_arc*` configuration keys are server-owned and ignored.',
|
|
51
|
+
requestBody: { schema: cpqAddQuoteItemSchema },
|
|
52
|
+
responses: [{ status: 201, description: 'Quote after the line was added' }],
|
|
53
|
+
errors: CPQ_GUARDED_WRITE_ERRORS,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { resolveCpqRouteContext } from '../../../../context'
|
|
5
|
+
import { cpqPriceAdjustmentPatchSchema } from '../../../../../data/price-adjustments'
|
|
6
|
+
import { adjustmentCommandRoute } from '../../../../_helpers/adjustmentCommandRoute'
|
|
7
|
+
import { handleArcError } from '../../../../_helpers/handleArcError'
|
|
8
|
+
import { cpqErrorDoc } from '../../../../_helpers/quoteRouteOpenApi'
|
|
9
|
+
|
|
10
|
+
export const metadata = {
|
|
11
|
+
PATCH: { requireAuth: true, requireFeatures: ['cpq.quotes.manage', 'cpq.quotes.adjust_price'] },
|
|
12
|
+
DELETE: { requireAuth: true, requireFeatures: ['cpq.quotes.manage', 'cpq.quotes.adjust_price'] },
|
|
13
|
+
}
|
|
14
|
+
const pathParams = z.object({ quoteId: z.string().uuid(), adjustmentId: z.string().uuid() })
|
|
15
|
+
const deletionSchema = z.object({ expectedRevision: z.coerce.number().int().min(0).optional() }).strict()
|
|
16
|
+
type Params = { params: Promise<{ quoteId: string; adjustmentId: string }> }
|
|
17
|
+
export async function PATCH(req: Request, { params }: Params) {
|
|
18
|
+
try {
|
|
19
|
+
const ctx = await resolveCpqRouteContext(req)
|
|
20
|
+
if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
21
|
+
const { quoteId, adjustmentId } = pathParams.parse(await params)
|
|
22
|
+
const body = cpqPriceAdjustmentPatchSchema.parse(await req.json())
|
|
23
|
+
return await adjustmentCommandRoute(req, ctx, quoteId, body, cpqPriceAdjustmentPatchSchema, 'update', adjustmentId)
|
|
24
|
+
} catch (error) { return handleArcError(error, 'cpq/price-adjustments.PATCH') }
|
|
25
|
+
}
|
|
26
|
+
export async function DELETE(req: Request, { params }: Params) {
|
|
27
|
+
try {
|
|
28
|
+
const ctx = await resolveCpqRouteContext(req)
|
|
29
|
+
if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
30
|
+
const { quoteId, adjustmentId } = pathParams.parse(await params)
|
|
31
|
+
const body = deletionSchema.parse(Object.fromEntries(new URL(req.url).searchParams))
|
|
32
|
+
return await adjustmentCommandRoute(req, ctx, quoteId, body, deletionSchema, 'delete', adjustmentId)
|
|
33
|
+
} catch (error) { return handleArcError(error, 'cpq/price-adjustments.DELETE') }
|
|
34
|
+
}
|
|
35
|
+
const errors = [cpqErrorDoc(400, 'Invalid input'), cpqErrorDoc(403, 'Missing permission or disabled feature'), cpqErrorDoc(404, 'Quote or adjustment not found'), cpqErrorDoc(409, 'Adjustment locked, stale revision or changed undo target'), cpqErrorDoc(422, 'Invalid adjustment or guard rejection')]
|
|
36
|
+
export const openApi: OpenApiRouteDoc = { tag: 'CPQ Quotes', pathParams, methods: {
|
|
37
|
+
PATCH: { summary: 'Update a negotiated quote price adjustment', requestBody: { schema: cpqPriceAdjustmentPatchSchema }, responses: [{ status: 200, description: 'Adjustment and updated quote' }], errors },
|
|
38
|
+
DELETE: { summary: 'Remove a negotiated quote price adjustment', query: deletionSchema, responses: [{ status: 200, description: 'Updated quote' }], errors },
|
|
39
|
+
} }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
|
+
import { resolveCpqRouteContext } from '../../../../context'
|
|
4
|
+
import { cpqPriceAdjustmentPreviewSchema } from '../../../../../data/price-adjustments'
|
|
5
|
+
import type { CpqAdjustmentService } from '../../../../../services/priceAdjustments/adjustmentService'
|
|
6
|
+
import { handleArcError } from '../../../../_helpers/handleArcError'
|
|
7
|
+
import { cpqErrorDoc, cpqQuoteIdPathParams } from '../../../../_helpers/quoteRouteOpenApi'
|
|
8
|
+
|
|
9
|
+
export const metadata = { POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage', 'cpq.quotes.adjust_price'] } }
|
|
10
|
+
export async function POST(req: Request, { params }: { params: Promise<{ quoteId: string }> }) {
|
|
11
|
+
try {
|
|
12
|
+
const ctx = await resolveCpqRouteContext(req)
|
|
13
|
+
if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
14
|
+
const { quoteId } = cpqQuoteIdPathParams.parse(await params)
|
|
15
|
+
const body = cpqPriceAdjustmentPreviewSchema.parse(await req.json())
|
|
16
|
+
const service = ctx.container.resolve<CpqAdjustmentService>('cpqAdjustmentService')
|
|
17
|
+
return NextResponse.json(await service.preview(quoteId, body.draft, body.replaceAdjustmentId, { tenantId: ctx.tenantId, organizationId: ctx.organizationId }))
|
|
18
|
+
} catch (error) { return handleArcError(error, 'cpq/price-adjustments/preview.POST') }
|
|
19
|
+
}
|
|
20
|
+
export const openApi: OpenApiRouteDoc = { tag: 'CPQ Quotes', pathParams: cpqQuoteIdPathParams, methods: { POST: {
|
|
21
|
+
summary: 'Preview a draft adjustment without saving or emitting events', requestBody: { schema: cpqPriceAdjustmentPreviewSchema },
|
|
22
|
+
responses: [{ status: 200, description: 'Draft quote and adjustment errors; nothing persisted' }],
|
|
23
|
+
errors: [cpqErrorDoc(400, 'Invalid draft'), cpqErrorDoc(403, 'Missing permission or disabled feature'), cpqErrorDoc(404, 'Quote, line or replaced adjustment not found'), cpqErrorDoc(409, 'Adjustment locked or stale revision'), cpqErrorDoc(422, 'Ineligible target or invalid reason')],
|
|
24
|
+
} } }
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { resolveCpqRouteContext } from '../../../context'
|
|
5
|
+
import { cpqPriceAdjustmentInputSchema } from '../../../../data/price-adjustments'
|
|
6
|
+
import type { CpqAdjustmentService } from '../../../../services/priceAdjustments/adjustmentService'
|
|
7
|
+
import { adjustmentCommandRoute } from '../../../_helpers/adjustmentCommandRoute'
|
|
8
|
+
import { handleArcError } from '../../../_helpers/handleArcError'
|
|
9
|
+
import { cpqErrorDoc, cpqQuoteIdPathParams } from '../../../_helpers/quoteRouteOpenApi'
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
GET: { requireAuth: true, requireFeatures: ['cpq.quotes.view'] },
|
|
13
|
+
POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage', 'cpq.quotes.adjust_price'] },
|
|
14
|
+
}
|
|
15
|
+
const listQuery = z.object({ page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(25) })
|
|
16
|
+
type Params = { params: Promise<{ quoteId: string }> }
|
|
17
|
+
export async function GET(req: Request, { params }: Params) {
|
|
18
|
+
try {
|
|
19
|
+
const ctx = await resolveCpqRouteContext(req)
|
|
20
|
+
if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
21
|
+
const { quoteId } = cpqQuoteIdPathParams.parse(await params)
|
|
22
|
+
const { page, pageSize } = listQuery.parse(Object.fromEntries(new URL(req.url).searchParams))
|
|
23
|
+
const service = ctx.container.resolve<CpqAdjustmentService>('cpqAdjustmentService')
|
|
24
|
+
return NextResponse.json(await service.list(quoteId, { organizationId: ctx.organizationId, tenantId: ctx.tenantId }, page, pageSize))
|
|
25
|
+
} catch (error) { return handleArcError(error, 'cpq/price-adjustments.GET') }
|
|
26
|
+
}
|
|
27
|
+
export async function POST(req: Request, { params }: Params) {
|
|
28
|
+
try {
|
|
29
|
+
const ctx = await resolveCpqRouteContext(req)
|
|
30
|
+
if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
31
|
+
const { quoteId } = cpqQuoteIdPathParams.parse(await params)
|
|
32
|
+
const body = cpqPriceAdjustmentInputSchema.parse(await req.json())
|
|
33
|
+
return await adjustmentCommandRoute(req, ctx, quoteId, body, cpqPriceAdjustmentInputSchema, 'create')
|
|
34
|
+
} catch (error) { return handleArcError(error, 'cpq/price-adjustments.POST') }
|
|
35
|
+
}
|
|
36
|
+
const errors = [cpqErrorDoc(400, 'Invalid input'), cpqErrorDoc(403, 'Missing permission or disabled feature'), cpqErrorDoc(404, 'Quote not found'), cpqErrorDoc(409, 'Adjustment locked or stale revision'), cpqErrorDoc(422, 'Invalid adjustment or guard rejection')]
|
|
37
|
+
export const openApi: OpenApiRouteDoc = { tag: 'CPQ Quotes', pathParams: cpqQuoteIdPathParams, methods: {
|
|
38
|
+
GET: { summary: 'List quote price adjustments', query: listQuery, responses: [{ status: 200, description: 'Paged adjustments, pricing revision, feature state and the reason options (`reasons: [{ value, label }]`)' }], errors },
|
|
39
|
+
POST: { summary: 'Create a negotiated quote price adjustment', requestBody: { schema: cpqPriceAdjustmentInputSchema }, responses: [{ status: 201, description: 'Adjustment and updated quote' }], errors },
|
|
40
|
+
} }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recalculate route contract.
|
|
3
|
+
*
|
|
4
|
+
* Route-level `requireFeatures` is enforced by the API dispatcher, so the
|
|
5
|
+
* permission contract is pinned through the exported `metadata`; the handler
|
|
6
|
+
* contract (body → guard → service call) is pinned with mocked collaborators.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const recalculate = jest.fn()
|
|
10
|
+
const runCpqMutationGuards = jest.fn()
|
|
11
|
+
const runAfterSuccess = jest.fn()
|
|
12
|
+
|
|
13
|
+
jest.mock('../../../../context', () => ({
|
|
14
|
+
resolveCpqRouteContext: jest.fn().mockResolvedValue({
|
|
15
|
+
auth: { sub: 'user-1', tenantId: 't-1' },
|
|
16
|
+
organizationId: 'org-1',
|
|
17
|
+
tenantId: 't-1',
|
|
18
|
+
}),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
jest.mock('../../../../resolveQuotingService', () => ({
|
|
22
|
+
resolveQuotingService: () => ({ recalculate }),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
jest.mock('../../../../_helpers/quoteMutationGuard', () => ({
|
|
26
|
+
CPQ_QUOTE_RESOURCE_KIND: 'cpq.quote',
|
|
27
|
+
runCpqMutationGuards: (...args: unknown[]) => runCpqMutationGuards(...args),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
import { metadata, openApi, POST } from '../route'
|
|
31
|
+
|
|
32
|
+
function postRequest(body: unknown): Request {
|
|
33
|
+
return new Request('http://localhost/api/cpq/quotes/cpq-1/recalculate', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const params = { params: Promise.resolve({ quoteId: 'cpq-1' }) }
|
|
41
|
+
|
|
42
|
+
describe('POST /api/cpq/quotes/{quoteId}/recalculate', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
recalculate.mockReset()
|
|
45
|
+
recalculate.mockResolvedValue({ id: 'cpq-1' })
|
|
46
|
+
runAfterSuccess.mockReset()
|
|
47
|
+
runCpqMutationGuards.mockReset()
|
|
48
|
+
runCpqMutationGuards.mockResolvedValue({ ok: true, runAfterSuccess })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('requires an authenticated user with cpq.quotes.manage (view-only users get 403 from the dispatcher)', () => {
|
|
52
|
+
expect(metadata.POST).toEqual({ requireAuth: true, requireFeatures: ['cpq.quotes.manage'] })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('documents the route in openApi', () => {
|
|
56
|
+
expect(openApi.methods?.POST?.errors).toEqual(expect.arrayContaining([expect.objectContaining({ status: 409 })]))
|
|
57
|
+
expect(openApi.methods?.POST?.requestBody).toBeDefined()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('runs the mutation guards before a saved recalculation and their after-success hooks after it', async () => {
|
|
61
|
+
const res = await POST(postRequest({ save: true }), params)
|
|
62
|
+
|
|
63
|
+
expect(res.status).toBe(200)
|
|
64
|
+
expect(runCpqMutationGuards).toHaveBeenCalledWith(
|
|
65
|
+
expect.anything(),
|
|
66
|
+
expect.any(Request),
|
|
67
|
+
expect.objectContaining({ resourceKind: 'cpq.quote', resourceId: 'cpq-1', operation: 'custom' }),
|
|
68
|
+
)
|
|
69
|
+
expect(recalculate).toHaveBeenCalledWith(
|
|
70
|
+
'cpq-1',
|
|
71
|
+
expect.objectContaining({ save: true }),
|
|
72
|
+
{ organizationId: 'org-1', tenantId: 't-1' },
|
|
73
|
+
)
|
|
74
|
+
expect(runAfterSuccess).toHaveBeenCalledTimes(1)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('skips the guards for a dry run (save=false)', async () => {
|
|
78
|
+
await POST(postRequest({ save: false }), params)
|
|
79
|
+
|
|
80
|
+
expect(runCpqMutationGuards).not.toHaveBeenCalled()
|
|
81
|
+
expect(recalculate).toHaveBeenCalled()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('returns the guard response and does not recalculate when a guard blocks', async () => {
|
|
85
|
+
runCpqMutationGuards.mockResolvedValue({
|
|
86
|
+
ok: false,
|
|
87
|
+
response: Response.json({ error: 'Quote awaits approval' }, { status: 423 }),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const res = await POST(postRequest({ save: true }), params)
|
|
91
|
+
|
|
92
|
+
expect(res.status).toBe(423)
|
|
93
|
+
expect(recalculate).not.toHaveBeenCalled()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -4,9 +4,15 @@ import { resolveCpqRouteContext } from '../../../context'
|
|
|
4
4
|
import { cpqRecalculateSchema } from '../../../../data/validators'
|
|
5
5
|
import { isQuotingError } from '../../../../services/cpqQuotingService'
|
|
6
6
|
import { resolveQuotingService } from '../../../resolveQuotingService'
|
|
7
|
+
import { CPQ_QUOTE_RESOURCE_KIND, runCpqMutationGuards } from '../../../_helpers/quoteMutationGuard'
|
|
8
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
|
+
import { cpqErrorDoc, cpqQuoteIdPathParams } from '../../../_helpers/quoteRouteOpenApi'
|
|
7
10
|
|
|
11
|
+
// Recalculation re-resolves and (with `save: true`) persists prices, so it
|
|
12
|
+
// needs the same feature as any other quote write. The quote page always
|
|
13
|
+
// saves; viewers read stored prices through GET /api/cpq/quotes/{quoteId}.
|
|
8
14
|
export const metadata = {
|
|
9
|
-
POST: { requireAuth: true, requireFeatures: ['cpq.quotes.
|
|
15
|
+
POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
export async function POST(req: Request, { params }: { params: Promise<{ quoteId: string }> }) {
|
|
@@ -18,8 +24,20 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
|
|
|
18
24
|
const body = cpqRecalculateSchema.parse(await req.json())
|
|
19
25
|
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
20
26
|
|
|
27
|
+
// Only a saved recalculation writes; a dry run needs no guard.
|
|
28
|
+
const guard = body.save
|
|
29
|
+
? await runCpqMutationGuards(ctx, req, {
|
|
30
|
+
resourceKind: CPQ_QUOTE_RESOURCE_KIND,
|
|
31
|
+
resourceId: quoteId,
|
|
32
|
+
operation: 'custom',
|
|
33
|
+
mutationPayload: { action: 'recalculate' },
|
|
34
|
+
})
|
|
35
|
+
: null
|
|
36
|
+
if (guard && !guard.ok) return guard.response
|
|
37
|
+
|
|
21
38
|
const service = resolveQuotingService(ctx)
|
|
22
39
|
const result = await service.recalculate(quoteId, body, scope)
|
|
40
|
+
if (guard) await guard.runAfterSuccess()
|
|
23
41
|
return NextResponse.json(result)
|
|
24
42
|
} catch (err) {
|
|
25
43
|
if (err instanceof z.ZodError) {
|
|
@@ -32,3 +50,24 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
|
|
|
32
50
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
33
51
|
}
|
|
34
52
|
}
|
|
53
|
+
|
|
54
|
+
export const openApi: OpenApiRouteDoc = {
|
|
55
|
+
tag: 'CPQ Quotes',
|
|
56
|
+
summary: 'CPQ quote recalculation',
|
|
57
|
+
pathParams: cpqQuoteIdPathParams,
|
|
58
|
+
methods: {
|
|
59
|
+
POST: {
|
|
60
|
+
summary: 'Re-price every line of a CPQ quote',
|
|
61
|
+
description:
|
|
62
|
+
'Re-resolves charges and price rules. With `save: true` the result is persisted (refused on accepted, rejected and cancelled quotes) and mutation guards (resource kind `cpq.quote`, action `recalculate`) run first. Frozen Amend mirror lines keep their stored price.',
|
|
63
|
+
requestBody: { schema: cpqRecalculateSchema },
|
|
64
|
+
responses: [{ status: 200, description: 'Recalculated quote' }],
|
|
65
|
+
errors: [
|
|
66
|
+
cpqErrorDoc(400, 'Validation error'),
|
|
67
|
+
cpqErrorDoc(404, 'Quote not found'),
|
|
68
|
+
cpqErrorDoc(409, 'Saved recalculation on a terminal quote'),
|
|
69
|
+
cpqErrorDoc(422, 'Blocked by a mutation guard'),
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const transitionStatus = jest.fn()
|
|
2
|
+
const runCpqMutationGuards = jest.fn()
|
|
3
|
+
const runAfterSuccess = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('../../../../context', () => ({
|
|
6
|
+
resolveCpqRouteContext: jest.fn().mockResolvedValue({
|
|
7
|
+
auth: { sub: '77777777-7777-4777-8777-777777777777', tenantId: 't-1' },
|
|
8
|
+
organizationId: 'org-1',
|
|
9
|
+
tenantId: 't-1',
|
|
10
|
+
}),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.mock('../../../../resolveQuotingService', () => ({
|
|
14
|
+
resolveQuotingService: () => ({ transitionStatus }),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('../../../../_helpers/quoteMutationGuard', () => ({
|
|
18
|
+
CPQ_QUOTE_RESOURCE_KIND: 'cpq.quote',
|
|
19
|
+
runCpqMutationGuards: (...args: unknown[]) => runCpqMutationGuards(...args),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
import { POST } from '../route'
|
|
23
|
+
|
|
24
|
+
function postStatus(targetStatus: string): Request {
|
|
25
|
+
return new Request('http://localhost/api/cpq/quotes/cpq-1/status', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({ targetStatus }),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const params = { params: Promise.resolve({ quoteId: 'cpq-1' }) }
|
|
33
|
+
|
|
34
|
+
describe('POST /api/cpq/quotes/{quoteId}/status — mutation guards', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
transitionStatus.mockReset()
|
|
37
|
+
transitionStatus.mockResolvedValue({ id: 'cpq-1', cpqStatus: 'with_customer' })
|
|
38
|
+
runAfterSuccess.mockReset()
|
|
39
|
+
runCpqMutationGuards.mockReset()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('transitions when the guards pass and then runs their after-success hooks', async () => {
|
|
43
|
+
runCpqMutationGuards.mockResolvedValue({ ok: true, runAfterSuccess })
|
|
44
|
+
|
|
45
|
+
const res = await POST(postStatus('with_customer'), params)
|
|
46
|
+
|
|
47
|
+
expect(res.status).toBe(200)
|
|
48
|
+
expect(runCpqMutationGuards).toHaveBeenCalledWith(
|
|
49
|
+
expect.anything(),
|
|
50
|
+
expect.any(Request),
|
|
51
|
+
{
|
|
52
|
+
resourceKind: 'cpq.quote',
|
|
53
|
+
resourceId: 'cpq-1',
|
|
54
|
+
operation: 'custom',
|
|
55
|
+
mutationPayload: { action: 'transition_status', targetStatus: 'with_customer' },
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
expect(transitionStatus).toHaveBeenCalledWith('cpq-1', 'with_customer', { organizationId: 'org-1', tenantId: 't-1' }, '77777777-7777-4777-8777-777777777777')
|
|
59
|
+
expect(runAfterSuccess).toHaveBeenCalledTimes(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns the guard response and leaves the status unchanged when a project guard blocks', async () => {
|
|
63
|
+
runCpqMutationGuards.mockResolvedValue({
|
|
64
|
+
ok: false,
|
|
65
|
+
response: Response.json({ error: 'Discount above 15% needs approval' }, { status: 422 }),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const res = await POST(postStatus('with_customer'), params)
|
|
69
|
+
|
|
70
|
+
expect(res.status).toBe(422)
|
|
71
|
+
expect(await res.json()).toEqual({ error: 'Discount above 15% needs approval' })
|
|
72
|
+
expect(transitionStatus).not.toHaveBeenCalled()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { resolveCpqRouteContext } from '../../../context'
|
|
3
|
+
import { resolveAuditActor } from '../../../../lib/auditActor'
|
|
3
4
|
import { cpqTransitionStatusSchema } from '../../../../data/validators'
|
|
4
5
|
import { resolveQuotingService } from '../../../resolveQuotingService'
|
|
5
6
|
import { handleArcError } from '../../../_helpers/handleArcError'
|
|
7
|
+
import { CPQ_QUOTE_RESOURCE_KIND, runCpqMutationGuards } from '../../../_helpers/quoteMutationGuard'
|
|
8
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
|
+
import { cpqErrorDoc, cpqQuoteIdPathParams } from '../../../_helpers/quoteRouteOpenApi'
|
|
6
10
|
|
|
7
11
|
export const metadata = {
|
|
8
12
|
POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
|
|
@@ -17,10 +21,42 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
|
|
|
17
21
|
const body = cpqTransitionStatusSchema.parse(await req.json())
|
|
18
22
|
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
19
23
|
|
|
24
|
+
// Projects enforce approval rules here (e.g. block `ready → with_customer`
|
|
25
|
+
// until a discounted quote is approved).
|
|
26
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
27
|
+
resourceKind: CPQ_QUOTE_RESOURCE_KIND,
|
|
28
|
+
resourceId: quoteId,
|
|
29
|
+
operation: 'custom',
|
|
30
|
+
mutationPayload: { action: 'transition_status', targetStatus: body.targetStatus },
|
|
31
|
+
})
|
|
32
|
+
if (!guard.ok) return guard.response
|
|
33
|
+
|
|
20
34
|
const service = resolveQuotingService(ctx)
|
|
21
|
-
const result = await service.transitionStatus(quoteId, body.targetStatus, scope)
|
|
35
|
+
const result = await service.transitionStatus(quoteId, body.targetStatus, scope, resolveAuditActor(ctx.auth))
|
|
36
|
+
await guard.runAfterSuccess()
|
|
22
37
|
return NextResponse.json(result)
|
|
23
38
|
} catch (err) {
|
|
24
39
|
return handleArcError(err, 'cpq/quotes/[quoteId]/status.POST')
|
|
25
40
|
}
|
|
26
41
|
}
|
|
42
|
+
|
|
43
|
+
export const openApi: OpenApiRouteDoc = {
|
|
44
|
+
tag: 'CPQ Quotes',
|
|
45
|
+
summary: 'CPQ quote status',
|
|
46
|
+
pathParams: cpqQuoteIdPathParams,
|
|
47
|
+
methods: {
|
|
48
|
+
POST: {
|
|
49
|
+
summary: 'Transition a CPQ quote to another status',
|
|
50
|
+
description:
|
|
51
|
+
'Operator-driven status change. Registered mutation guards (resource kind `cpq.quote`, action `transition_status`) run first — projects use them to enforce approvals.',
|
|
52
|
+
requestBody: { schema: cpqTransitionStatusSchema },
|
|
53
|
+
responses: [{ status: 200, description: 'Quote with its new status' }],
|
|
54
|
+
errors: [
|
|
55
|
+
cpqErrorDoc(400, 'Validation error'),
|
|
56
|
+
cpqErrorDoc(404, 'Quote not found'),
|
|
57
|
+
cpqErrorDoc(409, 'Transition not allowed'),
|
|
58
|
+
cpqErrorDoc(422, 'ARC validation failed or blocked by a mutation guard'),
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const resolveProductCharges = jest.fn()
|
|
2
|
+
jest.mock('../../../context', () => ({ resolveCpqRouteContext: async () => ({ auth: { sub: 'user' }, tenantId: 'tenant', organizationId: 'org', container: { resolve: () => ({ resolveProductCharges }) } }) }))
|
|
3
|
+
import { POST, openApi } from '../route'
|
|
4
|
+
const offeringId = '11111111-1111-4111-8111-111111111111'
|
|
5
|
+
const charge = { chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'flat', quantity: 1,
|
|
6
|
+
unitPrice: 90, totalPrice: 90, currencyCode: 'USD', listUnitPrice: 100, listTotalPrice: 100, standardUnitPrice: 90, standardTotalPrice: 90,
|
|
7
|
+
adjustments: [{ origin: 'rule', ruleCode: 'volume', ruleName: 'Volume', ruleType: 'discount_percent', value: 10, unitPriceBefore: 100, unitPriceAfter: 90, delta: -10 }] }
|
|
8
|
+
const body = { primaryItem: { offeringId, configuration: {} }, childItems: [{ offeringId, configuration: {} }], currencyCode: 'USD' }
|
|
9
|
+
function request(input: unknown) { return new Request('http://localhost/api/cpq/quotes/price', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }) }
|
|
10
|
+
beforeEach(() => { resolveProductCharges.mockReset(); resolveProductCharges.mockResolvedValue([charge]) })
|
|
11
|
+
it('keeps waterfall fields out of primary and child prices by default while tagging rules', async () => {
|
|
12
|
+
const response = await POST(request(body))
|
|
13
|
+
expect(response.status).toBe(200)
|
|
14
|
+
const result = await response.json()
|
|
15
|
+
for (const item of [result.primaryCharges[0], result.childItems[0].charges[0]]) {
|
|
16
|
+
expect(item).not.toHaveProperty('listUnitPrice')
|
|
17
|
+
expect(item).not.toHaveProperty('listTotalPrice')
|
|
18
|
+
expect(item).not.toHaveProperty('standardUnitPrice')
|
|
19
|
+
expect(item).not.toHaveProperty('standardTotalPrice')
|
|
20
|
+
expect(item.adjustments[0].origin).toBe('rule')
|
|
21
|
+
}
|
|
22
|
+
expect(result.totals.mrcTotal).toBe(180)
|
|
23
|
+
})
|
|
24
|
+
it('returns list and standard fields only with explicit includeWaterfall', async () => {
|
|
25
|
+
const result = await (await POST(request({ ...body, includeWaterfall: true }))).json()
|
|
26
|
+
expect(result.primaryCharges[0]).toEqual(charge)
|
|
27
|
+
expect(result.childItems[0].charges[0]).toEqual(charge)
|
|
28
|
+
})
|
|
29
|
+
it('never forwards draft adjustments to the catalog engine', async () => {
|
|
30
|
+
await POST(request({ ...body, priceAdjustments: [{ value: 80 }] }))
|
|
31
|
+
expect(resolveProductCharges).toHaveBeenCalledWith({ offeringId, productId: undefined, configuration: {}, currencyCode: 'USD', tenantId: 'tenant', organizationId: 'org' })
|
|
32
|
+
})
|
|
33
|
+
it('rejects malformed waterfall flags and documents the request contract', async () => {
|
|
34
|
+
expect((await POST(request({ ...body, includeWaterfall: 'yes' }))).status).toBe(400)
|
|
35
|
+
expect(resolveProductCharges).not.toHaveBeenCalled()
|
|
36
|
+
expect(openApi.methods.POST?.requestBody).toBeDefined()
|
|
37
|
+
})
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { cpqErrorDoc } from '../../_helpers/quoteRouteOpenApi'
|
|
3
5
|
import { resolveCpqRouteContext } from '../../context'
|
|
4
6
|
import { cpqPricePreviewSchema } from '../../../data/validators'
|
|
5
7
|
import type { DefaultCpqPricingService } from '../../../services/cpqPricingService'
|
|
6
8
|
import type { ResolvedCharge } from '../../../services/types'
|
|
7
9
|
import { getBaseCurrencyCode } from '../../../services/types'
|
|
10
|
+
import { withChargeWaterfall, withoutChargeWaterfall } from '../../../services/priceAdjustments/chargeWaterfall'
|
|
8
11
|
|
|
9
12
|
export const metadata = {
|
|
10
13
|
POST: { requireAuth: true, requireFeatures: ['cpq.quotes.view'] },
|
|
@@ -65,9 +68,10 @@ export async function POST(req: Request) {
|
|
|
65
68
|
note: c.note ?? 'Usage-based charge — billed on actual consumption.',
|
|
66
69
|
}))
|
|
67
70
|
|
|
71
|
+
const presentCharge = body.includeWaterfall ? withChargeWaterfall : withoutChargeWaterfall
|
|
68
72
|
return NextResponse.json({
|
|
69
|
-
primaryCharges,
|
|
70
|
-
childItems,
|
|
73
|
+
primaryCharges: primaryCharges.map(presentCharge),
|
|
74
|
+
childItems: childItems.map((item) => ({ ...item, charges: item.charges.map(presentCharge) })),
|
|
71
75
|
totals: { nrcTotal, mrcTotal, usageCharges },
|
|
72
76
|
currencyCode,
|
|
73
77
|
})
|
|
@@ -79,3 +83,25 @@ export async function POST(req: Request) {
|
|
|
79
83
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
80
84
|
}
|
|
81
85
|
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
const chargeDoc = z.object({
|
|
89
|
+
chargeCode: z.string(), chargeName: z.string(), chargeType: z.enum(['nrc', 'mrc', 'usage']),
|
|
90
|
+
pricingMethod: z.enum(['flat', 'per_unit', 'volume', 'tiered']), currencyCode: z.string(),
|
|
91
|
+
unitPrice: z.number(), quantity: z.number().nullable(), totalPrice: z.number().nullable(),
|
|
92
|
+
listUnitPrice: z.number().optional(), listTotalPrice: z.number().nullable().optional(),
|
|
93
|
+
standardUnitPrice: z.number().optional(), standardTotalPrice: z.number().nullable().optional(),
|
|
94
|
+
adjustments: z.array(z.object({ origin: z.literal('rule'), ruleCode: z.string(), ruleName: z.string(),
|
|
95
|
+
ruleType: z.string(), value: z.number(), unitPriceBefore: z.number(), unitPriceAfter: z.number(), delta: z.number() }).passthrough()).nullish(),
|
|
96
|
+
}).passthrough()
|
|
97
|
+
export const openApi: OpenApiRouteDoc = { tag: 'CPQ Pricing', methods: { POST: {
|
|
98
|
+
summary: 'Calculate catalog prices and price rules without saving',
|
|
99
|
+
description: 'Manual quote adjustments are never applied. Set includeWaterfall=true to return list and standard prices; these fields are omitted by default.',
|
|
100
|
+
requestBody: { schema: cpqPricePreviewSchema },
|
|
101
|
+
responses: [{ status: 200, description: 'Resolved rule prices and optional waterfall', schema: z.object({
|
|
102
|
+
primaryCharges: z.array(chargeDoc),
|
|
103
|
+
childItems: z.array(z.object({ offeringId: z.string().nullable(), productId: z.string().nullable(), charges: z.array(chargeDoc) })),
|
|
104
|
+
totals: z.object({ nrcTotal: z.number(), mrcTotal: z.number(), usageCharges: z.array(z.unknown()) }), currencyCode: z.string(),
|
|
105
|
+
}) }],
|
|
106
|
+
errors: [cpqErrorDoc(400, 'Invalid pricing request'), cpqErrorDoc(500, 'Price calculation failed')],
|
|
107
|
+
} } }
|
|
@@ -10,7 +10,7 @@ export const metadata = {
|
|
|
10
10
|
pageTitleKey: 'cpq.inventory.title',
|
|
11
11
|
pageGroup: 'CPQ',
|
|
12
12
|
pageGroupKey: 'customers~sales~cpq.nav.group',
|
|
13
|
-
pageOrder:
|
|
13
|
+
pageOrder: 3,
|
|
14
14
|
icon: inventoryIcon,
|
|
15
15
|
breadcrumb: [{ label: 'CPQ Subscriptions', labelKey: 'cpq.inventory.title' }],
|
|
16
16
|
} as const
|