@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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { test, expect, type Page } from '@playwright/test'
|
|
2
|
+
import { login } from '@open-mercato/core/helpers/integration/auth'
|
|
3
|
+
import { getAuthToken, apiRequest } from '@open-mercato/core/helpers/integration/api'
|
|
4
|
+
import {
|
|
5
|
+
seedActiveSubscription,
|
|
6
|
+
seedCustomer,
|
|
7
|
+
deleteSubscription,
|
|
8
|
+
deleteCustomer,
|
|
9
|
+
hideFloatingOverlays,
|
|
10
|
+
uniq,
|
|
11
|
+
} from './_helpers/cpqArcFixtures'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TC-CPQ-ARC-012 — Amend: removing an existing item cancels it on the subscription
|
|
15
|
+
* (manual: xd-250-arc-flow.md → "Removing an existing item on Amend / Renew quotes")
|
|
16
|
+
*
|
|
17
|
+
* Deleting a line that mirrors a live subscription item must not drop the
|
|
18
|
+
* line — activation only applies explicit line actions, so the item would
|
|
19
|
+
* silently stay active. Instead a confirmation dialog explains the effect,
|
|
20
|
+
* the line becomes a greyed-out `remove` line, the ARC summary keeps it in
|
|
21
|
+
* Current MRC but not in New MRC, and Restore brings it back. After
|
|
22
|
+
* convert + activate, the removed item is terminated and the kept one stays.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
type QuoteLine = { lineId: string; offeringName: string; action: string }
|
|
26
|
+
type SubscriptionItem = { id: string; name: string; status: string }
|
|
27
|
+
|
|
28
|
+
function lineRow(page: Page, name: string) {
|
|
29
|
+
return page.getByTestId('cpq-quote-line').filter({ hasText: name })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test.describe('TC-CPQ-ARC-012 — Amend: remove an existing item', () => {
|
|
33
|
+
test('confirm → remove line → MRC delta → restore → remove → activate terminates the item', async ({ page, request }) => {
|
|
34
|
+
const token = await getAuthToken(request, 'admin')
|
|
35
|
+
const customerId = await seedCustomer(request, token)
|
|
36
|
+
const keptName = `Kept Port ${uniq()}`
|
|
37
|
+
const removedName = `Removed Storage ${uniq()}`
|
|
38
|
+
const sub = await seedActiveSubscription(request, token, {
|
|
39
|
+
customerId,
|
|
40
|
+
name: 'Remove-item target',
|
|
41
|
+
items: [
|
|
42
|
+
{ name: keptName, mrcAmount: 250 },
|
|
43
|
+
{ name: removedName, mrcAmount: 100 },
|
|
44
|
+
],
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await login(page, 'admin')
|
|
49
|
+
|
|
50
|
+
// 1. Amend from the subscription.
|
|
51
|
+
await page.goto(`/backend/cpq/inventory/subscriptions/${sub.id}`, { waitUntil: 'domcontentloaded' })
|
|
52
|
+
await hideFloatingOverlays(page)
|
|
53
|
+
await page.getByRole('button', { name: /^Amend$/ }).click()
|
|
54
|
+
await page.waitForURL(/\/backend\/cpq\/quotes\/[0-9a-f-]+$/, { timeout: 15_000 })
|
|
55
|
+
const quoteId = page.url().split('/').pop()!
|
|
56
|
+
await hideFloatingOverlays(page)
|
|
57
|
+
|
|
58
|
+
const removedRow = lineRow(page, removedName)
|
|
59
|
+
await expect(removedRow).toBeVisible()
|
|
60
|
+
await expect(removedRow).not.toHaveAttribute('data-removed', 'true')
|
|
61
|
+
|
|
62
|
+
// 2. Trash → confirmation dialog explains the item is cancelled. Cancel keeps the line.
|
|
63
|
+
await removedRow.getByTitle('Remove').click()
|
|
64
|
+
const dialog = page.getByRole('alertdialog')
|
|
65
|
+
await expect(dialog).toContainText(`Remove ${removedName} from the subscription?`)
|
|
66
|
+
await expect(dialog).toContainText('cancelled on the subscription')
|
|
67
|
+
await dialog.getByRole('button', { name: /^Cancel$/ }).click()
|
|
68
|
+
await expect(dialog).toBeHidden()
|
|
69
|
+
await expect(removedRow).not.toHaveAttribute('data-removed', 'true')
|
|
70
|
+
|
|
71
|
+
// 3. Confirm → the line stays as a `remove` line with a Restore action.
|
|
72
|
+
await removedRow.getByTitle('Remove').click()
|
|
73
|
+
await page.getByRole('alertdialog').getByRole('button', { name: 'Remove item' }).click()
|
|
74
|
+
await expect(removedRow).toHaveAttribute('data-removed', 'true')
|
|
75
|
+
await expect(removedRow.getByText(/^remove$/)).toBeVisible()
|
|
76
|
+
await expect(removedRow.getByTestId('cpq-line-restore')).toBeVisible()
|
|
77
|
+
|
|
78
|
+
// 4. Summary: removed item stays in Current MRC, drops out of New MRC.
|
|
79
|
+
await expect(page.getByTestId('cpq-arc-current-mrc')).toContainText('$350.00')
|
|
80
|
+
await expect(page.getByTestId('cpq-arc-new-mrc')).toContainText('$250.00')
|
|
81
|
+
await expect(page.getByTestId('cpq-arc-mrc-change')).toContainText('-$100.00')
|
|
82
|
+
|
|
83
|
+
// 5. Restore → back to a normal line; totals back to unchanged.
|
|
84
|
+
await removedRow.getByTestId('cpq-line-restore').click()
|
|
85
|
+
await expect(removedRow).not.toHaveAttribute('data-removed', 'true')
|
|
86
|
+
await expect(page.getByTestId('cpq-arc-new-mrc')).toContainText('$350.00')
|
|
87
|
+
|
|
88
|
+
// 6. Remove again for the activation leg.
|
|
89
|
+
await removedRow.getByTitle('Remove').click()
|
|
90
|
+
await page.getByRole('alertdialog').getByRole('button', { name: 'Remove item' }).click()
|
|
91
|
+
await expect(removedRow).toHaveAttribute('data-removed', 'true')
|
|
92
|
+
|
|
93
|
+
// The API agrees: one cancel line, one modify line.
|
|
94
|
+
const quoteRes = await apiRequest(request, 'GET', `/api/cpq/quotes/${quoteId}`, { token })
|
|
95
|
+
expect(quoteRes.ok()).toBeTruthy()
|
|
96
|
+
const quote = (await quoteRes.json()) as { cpqStatus: string; lines: QuoteLine[] }
|
|
97
|
+
expect(quote.lines.find((l) => l.offeringName === removedName)?.action).toBe('cancel')
|
|
98
|
+
expect(quote.lines.find((l) => l.offeringName === keptName)?.action).toBe('modify')
|
|
99
|
+
|
|
100
|
+
// 7. API — approval ladder (from wherever recalculation left the quote), convert, activate.
|
|
101
|
+
const ladder = ['ready', 'in_approval', 'approved', 'with_customer', 'accepted']
|
|
102
|
+
for (const targetStatus of ladder.slice(ladder.indexOf(quote.cpqStatus) + 1)) {
|
|
103
|
+
const res = await apiRequest(request, 'POST', `/api/cpq/quotes/${quoteId}/status`, { token, data: { targetStatus } })
|
|
104
|
+
expect(res.ok(), `transition to ${targetStatus} returned ${res.status()}: ${await res.text()}`).toBeTruthy()
|
|
105
|
+
}
|
|
106
|
+
const convertRes = await apiRequest(request, 'POST', '/api/cpq/orders', { token, data: { quoteId } })
|
|
107
|
+
expect(convertRes.ok(), `convert returned ${convertRes.status()}: ${await convertRes.text()}`).toBeTruthy()
|
|
108
|
+
const order = (await convertRes.json()) as { id: string }
|
|
109
|
+
|
|
110
|
+
// The order page mirrors the quote: same ARC summary, removed line marked "remove".
|
|
111
|
+
await page.goto(`/backend/cpq/orders/${order.id}`, { waitUntil: 'domcontentloaded' })
|
|
112
|
+
await hideFloatingOverlays(page)
|
|
113
|
+
await expect(page.getByTestId('cpq-pricing-summary')).toBeVisible()
|
|
114
|
+
await expect(page.getByTestId('cpq-arc-current-mrc')).toContainText('$350.00')
|
|
115
|
+
await expect(page.getByTestId('cpq-arc-new-mrc')).toContainText('$250.00')
|
|
116
|
+
const removedOrderLine = page.getByTestId('cpq-order-line').filter({ hasText: removedName })
|
|
117
|
+
await expect(removedOrderLine).toHaveAttribute('data-removed', 'true')
|
|
118
|
+
await expect(removedOrderLine.getByText(/^remove$/)).toBeVisible()
|
|
119
|
+
const activateRes = await apiRequest(request, 'POST', `/api/cpq/orders/${order.id}/activate`, { token })
|
|
120
|
+
expect(activateRes.ok(), `activate returned ${activateRes.status()}: ${await activateRes.text()}`).toBeTruthy()
|
|
121
|
+
|
|
122
|
+
// 8. The removed item is terminated; the other one is untouched.
|
|
123
|
+
const itemsRes = await apiRequest(request, 'GET', `/api/cpq/inventory/subscription-items?subscriptionId=${sub.id}`, { token })
|
|
124
|
+
expect(itemsRes.ok()).toBeTruthy()
|
|
125
|
+
const body = (await itemsRes.json()) as SubscriptionItem[] | { items: SubscriptionItem[] }
|
|
126
|
+
const items = Array.isArray(body) ? body : body.items
|
|
127
|
+
expect(items.find((i) => i.name === removedName)?.status).toBe('terminated')
|
|
128
|
+
expect(items.find((i) => i.name === keptName)?.status).toBe('active')
|
|
129
|
+
} finally {
|
|
130
|
+
await deleteSubscription(request, token, sub.id).catch(() => undefined)
|
|
131
|
+
await deleteCustomer(request, token, customerId).catch(() => undefined)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
package/src/modules/cpq/acl.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const features = [
|
|
2
|
+
{ id: 'cpq.quotes.adjust_price', title: 'Adjust CPQ quote prices', module: 'cpq' },
|
|
2
3
|
{ id: 'cpq.quotes.view', title: 'View CPQ Quotes', module: 'cpq' },
|
|
3
4
|
{ id: 'cpq.quotes.manage', title: 'Manage CPQ Quotes', module: 'cpq' },
|
|
4
5
|
{ id: 'cpq.pricing.view', title: 'View Pricing', module: 'cpq' },
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const execute = jest.fn(), list = jest.fn(), preview = jest.fn(), guards = jest.fn(), after = jest.fn()
|
|
2
|
+
const ctx = { auth: { sub: 'user', tenantId: 'tenant' }, tenantId: 'tenant', organizationId: 'org', organizationScope: null,
|
|
3
|
+
container: { resolve: (name: string) => name === 'commandBus' ? { execute } : { list, preview } } }
|
|
4
|
+
jest.mock('../context', () => ({ resolveCpqRouteContext: jest.fn(async () => ctx) }))
|
|
5
|
+
jest.mock('../_helpers/quoteMutationGuard', () => ({ runCpqMutationGuards: (...args: unknown[]) => guards(...args) }))
|
|
6
|
+
import { GET, POST, metadata } from '../quotes/[quoteId]/price-adjustments/route'
|
|
7
|
+
import { POST as PREVIEW } from '../quotes/[quoteId]/price-adjustments/preview/route'
|
|
8
|
+
import { PATCH, DELETE } from '../quotes/[quoteId]/price-adjustments/[adjustmentId]/route'
|
|
9
|
+
import { QuotingError } from '../../services/cpqQuotingService'
|
|
10
|
+
const quoteId = '11111111-1111-4111-8111-111111111111', adjustmentId = '22222222-2222-4222-8222-222222222222'
|
|
11
|
+
const params = { params: Promise.resolve({ quoteId, adjustmentId }) }
|
|
12
|
+
const input = { level: 'charge', quoteLineId: quoteId, chargeCode: 'fee', adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match' }
|
|
13
|
+
function request(method: string, body?: unknown, query = '') { return new Request(`http://localhost/api/cpq/quotes/${quoteId}/price-adjustments${query}`, { method, headers: { 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) }
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
jest.clearAllMocks()
|
|
16
|
+
guards.mockResolvedValue({ ok: true, runAfterSuccess: after })
|
|
17
|
+
execute.mockResolvedValue({ result: { adjustment: { id: adjustmentId }, quote: { id: quoteId } }, logEntry: null })
|
|
18
|
+
preview.mockResolvedValue({ quote: { id: quoteId }, errors: [] })
|
|
19
|
+
list.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 25, pricingRevision: 1 })
|
|
20
|
+
})
|
|
21
|
+
it('requires view on GET and both immutable manage/adjust features on POST', () => {
|
|
22
|
+
expect(metadata.GET.requireFeatures).toEqual(['cpq.quotes.view'])
|
|
23
|
+
expect(metadata.POST.requireFeatures).toEqual(['cpq.quotes.manage', 'cpq.quotes.adjust_price'])
|
|
24
|
+
})
|
|
25
|
+
it('creates through the command bus with server scope and guard hooks', async () => {
|
|
26
|
+
const response = await POST(request('POST', input), params)
|
|
27
|
+
expect(response.status).toBe(201)
|
|
28
|
+
expect(execute).toHaveBeenCalledWith('cpq.quote_price_adjustment.create', expect.objectContaining({ input: { quoteId, adjustment: input }, ctx: expect.objectContaining({ selectedOrganizationId: 'org', auth: ctx.auth }) }))
|
|
29
|
+
expect(guards).toHaveBeenCalledWith(ctx, expect.any(Request), expect.objectContaining({ resourceKind: 'cpq.quote_price_adjustment', operation: 'create', mutationPayload: { quoteId, ...input } }))
|
|
30
|
+
expect(after).toHaveBeenCalledTimes(1)
|
|
31
|
+
expect(execute.mock.invocationCallOrder[0]).toBeLessThan(after.mock.invocationCallOrder[0])
|
|
32
|
+
})
|
|
33
|
+
it('applies guard modifications but never replaces quote path identity', async () => {
|
|
34
|
+
guards.mockResolvedValue({ ok: true, modifiedPayload: { quoteId: adjustmentId, value: 70 }, runAfterSuccess: after })
|
|
35
|
+
await POST(request('POST', input), params)
|
|
36
|
+
expect(execute.mock.calls[0][1].input).toEqual({ quoteId, adjustment: { ...input, value: 70 } })
|
|
37
|
+
})
|
|
38
|
+
it('refuses invalid guard modifications and guard rejections before the command', async () => {
|
|
39
|
+
guards.mockResolvedValue({ ok: true, modifiedPayload: { value: -1 }, runAfterSuccess: after })
|
|
40
|
+
expect((await POST(request('POST', input), params)).status).toBe(400)
|
|
41
|
+
guards.mockResolvedValue({ ok: false, response: Response.json({ error: 'Approval required' }, { status: 423 }) })
|
|
42
|
+
expect((await POST(request('POST', input), params)).status).toBe(423)
|
|
43
|
+
expect(execute).not.toHaveBeenCalled()
|
|
44
|
+
expect(after).not.toHaveBeenCalled()
|
|
45
|
+
})
|
|
46
|
+
it.each([403, 404, 409, 422])('preserves command error %s without after-success', async (status) => {
|
|
47
|
+
execute.mockRejectedValue(new QuotingError(status, 'Rejected'))
|
|
48
|
+
const response = await POST(request('POST', input), params)
|
|
49
|
+
expect(response.status).toBe(status)
|
|
50
|
+
expect(await response.json()).toEqual({ error: 'Rejected' })
|
|
51
|
+
expect(after).not.toHaveBeenCalled()
|
|
52
|
+
})
|
|
53
|
+
it('updates immutable targets through PATCH and deletes with a revision token', async () => {
|
|
54
|
+
expect((await PATCH(request('PATCH', { value: 70 }), params)).status).toBe(200)
|
|
55
|
+
expect(execute.mock.calls[0][1].input).toEqual({ quoteId, adjustmentId, patch: { value: 70 } })
|
|
56
|
+
expect((await DELETE(request('DELETE', undefined, '?expectedRevision=3'), params)).status).toBe(200)
|
|
57
|
+
expect(execute.mock.calls[1][1].input).toEqual({ quoteId, adjustmentId, expectedRevision: 3 })
|
|
58
|
+
expect((await PATCH(request('PATCH', { quoteLineId: adjustmentId }), params)).status).toBe(400)
|
|
59
|
+
})
|
|
60
|
+
it('lists with bounded pagination and rejects malformed bodies and identifiers', async () => {
|
|
61
|
+
expect((await GET(request('GET', undefined, '?page=2&pageSize=10'), params)).status).toBe(200)
|
|
62
|
+
expect(list).toHaveBeenCalledWith(quoteId, { tenantId: 'tenant', organizationId: 'org' }, 2, 10)
|
|
63
|
+
expect((await GET(request('GET', undefined, '?pageSize=101'), params)).status).toBe(400)
|
|
64
|
+
expect((await POST(request('POST', {}), params)).status).toBe(400)
|
|
65
|
+
expect((await POST(request('POST', input), { params: Promise.resolve({ quoteId: 'bad' }) })).status).toBe(400)
|
|
66
|
+
})
|
|
67
|
+
it('returns the framework undo operation metadata for a logged command', async () => {
|
|
68
|
+
execute.mockResolvedValue({ result: { adjustment: { id: adjustmentId }, quote: { id: quoteId } },
|
|
69
|
+
logEntry: { id: 'log', commandId: 'cpq.quote_price_adjustment.create', undoToken: 'undo', createdAt: new Date() } })
|
|
70
|
+
expect((await POST(request('POST', input), params)).headers.get('x-om-operation')).toBeTruthy()
|
|
71
|
+
})
|
|
72
|
+
it('previews through the read-only service without commands or mutation hooks', async () => {
|
|
73
|
+
const response = await PREVIEW(request('POST', { draft: input }), params)
|
|
74
|
+
expect(response.status).toBe(200)
|
|
75
|
+
expect(preview).toHaveBeenCalledWith(quoteId, input, undefined, { tenantId: 'tenant', organizationId: 'org' })
|
|
76
|
+
expect(execute).not.toHaveBeenCalled()
|
|
77
|
+
expect(guards).not.toHaveBeenCalled()
|
|
78
|
+
expect((await PREVIEW(request('POST', { draft: {} }), params)).status).toBe(400)
|
|
79
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { OrderError } from '../../services/cpqOrderService'
|
|
2
|
+
const addQuoteItem = jest.fn()
|
|
3
|
+
const updateQuoteItem = jest.fn()
|
|
4
|
+
const removeQuoteItem = jest.fn()
|
|
5
|
+
const convertQuoteToOrder = jest.fn()
|
|
6
|
+
const runCpqMutationGuards = jest.fn()
|
|
7
|
+
const runAfterSuccess = jest.fn()
|
|
8
|
+
|
|
9
|
+
jest.mock('../context', () => ({
|
|
10
|
+
resolveCpqRouteContext: jest.fn().mockImplementation(async () => ({
|
|
11
|
+
auth: { sub: 'user-1' }, organizationId: 'org-1', tenantId: 'tenant-1',
|
|
12
|
+
container: { resolve: () => ({ convertQuoteToOrder }) },
|
|
13
|
+
})),
|
|
14
|
+
}))
|
|
15
|
+
jest.mock('../resolveQuotingService', () => ({
|
|
16
|
+
resolveQuotingService: () => ({ addQuoteItem, updateQuoteItem, removeQuoteItem }),
|
|
17
|
+
}))
|
|
18
|
+
jest.mock('../_helpers/quoteMutationGuard', () => ({
|
|
19
|
+
CPQ_QUOTE_RESOURCE_KIND: 'cpq.quote',
|
|
20
|
+
CPQ_QUOTE_LINE_RESOURCE_KIND: 'cpq.quote_line',
|
|
21
|
+
runCpqMutationGuards: (...args: unknown[]) => runCpqMutationGuards(...args),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
import { POST as add } from '../quotes/[quoteId]/items/route'
|
|
25
|
+
import { PUT as update, DELETE as remove } from '../quotes/[quoteId]/items/[lineId]/route'
|
|
26
|
+
import { POST as convert } from '../orders/route'
|
|
27
|
+
|
|
28
|
+
const quoteId = '11111111-1111-4111-8111-111111111111'
|
|
29
|
+
const lineId = '22222222-2222-4222-8222-222222222222'
|
|
30
|
+
const scope = { organizationId: 'org-1', tenantId: 'tenant-1' }
|
|
31
|
+
const params = { params: Promise.resolve({ quoteId, lineId }) }
|
|
32
|
+
function request(method: string, body?: unknown) {
|
|
33
|
+
return new Request('http://localhost/api/cpq/quotes', {
|
|
34
|
+
method, headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
const cases = [
|
|
39
|
+
{ name: 'add', invoke: () => add(request('POST', { configuration: {}, quantity: 1 }), params), service: addQuoteItem, operation: 'create', resourceKind: 'cpq.quote_line', status: 201 },
|
|
40
|
+
{ name: 'update', invoke: () => update(request('PUT', { configuration: {}, quantity: 1 }), params), service: updateQuoteItem, operation: 'update', resourceKind: 'cpq.quote_line', status: 200 },
|
|
41
|
+
{ name: 'remove', invoke: () => remove(request('DELETE'), params), service: removeQuoteItem, operation: 'delete', resourceKind: 'cpq.quote_line', status: 200 },
|
|
42
|
+
{ name: 'convert', invoke: () => convert(request('POST', { quoteId })), service: convertQuoteToOrder, operation: 'custom', resourceKind: 'cpq.quote', status: 201 },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
jest.clearAllMocks()
|
|
47
|
+
for (const entry of cases) entry.service.mockResolvedValue({ id: quoteId })
|
|
48
|
+
runCpqMutationGuards.mockResolvedValue({ ok: true, runAfterSuccess })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe.each(cases)('$name quote write guards', ({ invoke, service, operation, resourceKind, status }) => {
|
|
52
|
+
it('consults the guard before writing and invokes after-success after writing', async () => {
|
|
53
|
+
expect((await invoke()).status).toBe(status)
|
|
54
|
+
expect(runCpqMutationGuards).toHaveBeenCalledWith(expect.anything(), expect.any(Request),
|
|
55
|
+
expect.objectContaining({ operation, resourceKind }))
|
|
56
|
+
expect(service).toHaveBeenCalledTimes(1)
|
|
57
|
+
expect(runAfterSuccess).toHaveBeenCalledTimes(1)
|
|
58
|
+
expect(runCpqMutationGuards.mock.invocationCallOrder[0]).toBeLessThan(service.mock.invocationCallOrder[0])
|
|
59
|
+
expect(service.mock.invocationCallOrder[0]).toBeLessThan(runAfterSuccess.mock.invocationCallOrder[0])
|
|
60
|
+
})
|
|
61
|
+
it('returns a guard rejection without writing or calling after-success', async () => {
|
|
62
|
+
runCpqMutationGuards.mockResolvedValue({ ok: false, response: Response.json({ error: 'Approval required' }, { status: 423 }) })
|
|
63
|
+
const response = await invoke()
|
|
64
|
+
expect(response.status).toBe(423)
|
|
65
|
+
expect(await response.json()).toEqual({ error: 'Approval required' })
|
|
66
|
+
expect(service).not.toHaveBeenCalled()
|
|
67
|
+
expect(runAfterSuccess).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
it('does not call after-success if the write fails', async () => {
|
|
70
|
+
const log = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
71
|
+
try {
|
|
72
|
+
service.mockRejectedValueOnce(new Error('Write failed'))
|
|
73
|
+
expect((await invoke()).status).toBe(500)
|
|
74
|
+
expect(runAfterSuccess).not.toHaveBeenCalled()
|
|
75
|
+
} finally { log.mockRestore() }
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it.each(cases.slice(0, 2))('$name validates and applies guard-modified fields', async ({ invoke, service, name }) => {
|
|
80
|
+
runCpqMutationGuards.mockResolvedValue({ ok: true, modifiedPayload: { quantity: 3 }, runAfterSuccess })
|
|
81
|
+
await invoke()
|
|
82
|
+
const args = name === 'add'
|
|
83
|
+
? [quoteId, expect.objectContaining({ quantity: 3 }), scope]
|
|
84
|
+
: [quoteId, lineId, expect.objectContaining({ quantity: 3 }), scope]
|
|
85
|
+
expect(service).toHaveBeenCalledWith(...args)
|
|
86
|
+
})
|
|
87
|
+
it.each(cases.slice(0, 2))('$name refuses invalid guard-modified fields before writing', async ({ invoke, service }) => {
|
|
88
|
+
runCpqMutationGuards.mockResolvedValue({ ok: true, modifiedPayload: { quantity: 0 }, runAfterSuccess })
|
|
89
|
+
expect((await invoke()).status).toBe(400)
|
|
90
|
+
expect(service).not.toHaveBeenCalled()
|
|
91
|
+
expect(runAfterSuccess).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns the invalid adjustment line/code from conversion without running after-success', async () => {
|
|
95
|
+
convertQuoteToOrder.mockRejectedValueOnce(new OrderError(422, 'ADJUSTMENT_INVALID', { lineId, code: 'ABOVE_STANDARD' }))
|
|
96
|
+
const response = await convert(request('POST', { quoteId }))
|
|
97
|
+
expect(response.status).toBe(422)
|
|
98
|
+
expect(await response.json()).toEqual({ error: 'ADJUSTMENT_INVALID', lineId, code: 'ABOVE_STANDARD' })
|
|
99
|
+
expect(runAfterSuccess).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const runRouteMutationGuards = jest.fn()
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/crud/route-mutation-guard', () => ({
|
|
4
|
+
runRouteMutationGuards: (...args: unknown[]) => runRouteMutationGuards(...args),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
import { CPQ_QUOTE_RESOURCE_KIND, runCpqMutationGuards } from '../quoteMutationGuard'
|
|
8
|
+
|
|
9
|
+
describe('runCpqMutationGuards', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
runRouteMutationGuards.mockReset()
|
|
12
|
+
runRouteMutationGuards.mockResolvedValue({ ok: true, runAfterSuccess: jest.fn() })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('forwards the route actor, tenant scope and input to the framework guard runner', async () => {
|
|
16
|
+
const container = { resolve: jest.fn() }
|
|
17
|
+
const req = new Request('http://localhost/api/cpq/quotes/q-1/status', { method: 'POST' })
|
|
18
|
+
const input = {
|
|
19
|
+
resourceKind: CPQ_QUOTE_RESOURCE_KIND,
|
|
20
|
+
resourceId: 'q-1',
|
|
21
|
+
operation: 'custom' as const,
|
|
22
|
+
mutationPayload: { action: 'transition_status', targetStatus: 'with_customer' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await runCpqMutationGuards(
|
|
26
|
+
{ container: container as never, auth: { sub: 'user-1' } as never, tenantId: 't-1', organizationId: 'org-1' },
|
|
27
|
+
req,
|
|
28
|
+
input,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
expect(runRouteMutationGuards).toHaveBeenCalledWith({
|
|
32
|
+
container,
|
|
33
|
+
req,
|
|
34
|
+
auth: { userId: 'user-1', tenantId: 't-1', organizationId: 'org-1' },
|
|
35
|
+
input,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('passes a null organization when the route has none selected', async () => {
|
|
40
|
+
await runCpqMutationGuards(
|
|
41
|
+
{ container: {} as never, auth: { sub: 'user-1' } as never, tenantId: 't-1', organizationId: '' },
|
|
42
|
+
new Request('http://localhost/x', { method: 'POST' }),
|
|
43
|
+
{ resourceKind: CPQ_QUOTE_RESOURCE_KIND },
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(runRouteMutationGuards.mock.calls[0][0].auth.organizationId).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
4
|
+
import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
5
|
+
import type { CpqRouteContext } from '../context'
|
|
6
|
+
import { runCpqMutationGuards } from './quoteMutationGuard'
|
|
7
|
+
import type { AdjustmentWriteResult } from '../../services/priceAdjustments/adjustmentService'
|
|
8
|
+
|
|
9
|
+
export async function adjustmentCommandRoute<T extends object>(
|
|
10
|
+
req: Request, ctx: CpqRouteContext, quoteId: string, body: T,
|
|
11
|
+
schema: z.ZodType<T>, operation: 'create' | 'update' | 'delete', adjustmentId?: string,
|
|
12
|
+
): Promise<NextResponse | Response> {
|
|
13
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
14
|
+
resourceKind: 'cpq.quote_price_adjustment', resourceId: adjustmentId,
|
|
15
|
+
operation, mutationPayload: { quoteId, ...body },
|
|
16
|
+
})
|
|
17
|
+
if (!guard.ok) return guard.response
|
|
18
|
+
// Path identifiers belong to the server, even when a project guard returns
|
|
19
|
+
// the full payload including the original quoteId metadata.
|
|
20
|
+
const modified = Object.fromEntries(Object.entries(guard.modifiedPayload ?? {})
|
|
21
|
+
.filter(([key]) => key !== 'quoteId' && key !== 'adjustmentId'))
|
|
22
|
+
const effective = schema.parse({ ...body, ...modified })
|
|
23
|
+
const input = operation === 'create' ? { quoteId, adjustment: effective }
|
|
24
|
+
: operation === 'update' ? { quoteId, adjustmentId, patch: effective }
|
|
25
|
+
: { quoteId, adjustmentId, ...effective }
|
|
26
|
+
const commandCtx: CommandRuntimeContext = {
|
|
27
|
+
container: ctx.container, auth: ctx.auth, organizationScope: ctx.organizationScope,
|
|
28
|
+
selectedOrganizationId: ctx.organizationId, organizationIds: [ctx.organizationId], request: req,
|
|
29
|
+
}
|
|
30
|
+
const bus = ctx.container.resolve<CommandBus>('commandBus')
|
|
31
|
+
const commandId = `cpq.quote_price_adjustment.${operation}`
|
|
32
|
+
const { result, logEntry } = await bus.execute<typeof input, AdjustmentWriteResult>(commandId, { input, ctx: commandCtx })
|
|
33
|
+
if (!result) throw new Error('Adjustment command did not return a result')
|
|
34
|
+
await guard.runAfterSuccess()
|
|
35
|
+
const response = NextResponse.json(operation === 'delete' ? { quote: result.quote }
|
|
36
|
+
: { adjustment: result.adjustment, quote: result.quote }, { status: operation === 'create' ? 201 : 200 })
|
|
37
|
+
if (logEntry?.undoToken && logEntry.id && logEntry.commandId) {
|
|
38
|
+
response.headers.set('x-om-operation', serializeOperationMetadata({ id: logEntry.id, undoToken: logEntry.undoToken,
|
|
39
|
+
commandId: logEntry.commandId, actionLabel: logEntry.actionLabel ?? null,
|
|
40
|
+
resourceKind: 'cpq.quote_price_adjustment', resourceId: result.adjustment.id,
|
|
41
|
+
executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString()
|
|
42
|
+
: typeof logEntry.createdAt === 'string' ? logEntry.createdAt : new Date().toISOString() }))
|
|
43
|
+
}
|
|
44
|
+
return response
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runRouteMutationGuards,
|
|
3
|
+
type RouteMutationGuardInput,
|
|
4
|
+
type RouteMutationGuardResult,
|
|
5
|
+
} from '@open-mercato/shared/lib/crud/route-mutation-guard'
|
|
6
|
+
import type { CpqRouteContext } from '../context'
|
|
7
|
+
|
|
8
|
+
/** Mutation-guard resource kinds for CPQ quote writes (`module.entity`). */
|
|
9
|
+
export const CPQ_QUOTE_RESOURCE_KIND = 'cpq.quote'
|
|
10
|
+
export const CPQ_QUOTE_LINE_RESOURCE_KIND = 'cpq.quote_line'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run every registered mutation guard for a CPQ custom write route.
|
|
14
|
+
*
|
|
15
|
+
* Quote routes are hand-written (not `makeCrudRoute`), so they wire guards
|
|
16
|
+
* explicitly through the framework's registry-based wrapper. Projects use
|
|
17
|
+
* this hook to enforce rules CPQ does not own — e.g. blocking
|
|
18
|
+
* `ready → with_customer` or order conversion until an approval workflow
|
|
19
|
+
* signs a discounted quote off. With no guard registered the call passes.
|
|
20
|
+
*
|
|
21
|
+
* Call `runAfterSuccess()` on the passed result only after the write commits.
|
|
22
|
+
*/
|
|
23
|
+
export function runCpqMutationGuards(
|
|
24
|
+
ctx: Pick<CpqRouteContext, 'container' | 'auth' | 'tenantId' | 'organizationId'>,
|
|
25
|
+
req: Request,
|
|
26
|
+
input: RouteMutationGuardInput,
|
|
27
|
+
): Promise<RouteMutationGuardResult> {
|
|
28
|
+
return runRouteMutationGuards({
|
|
29
|
+
container: ctx.container,
|
|
30
|
+
req,
|
|
31
|
+
auth: {
|
|
32
|
+
userId: ctx.auth?.sub ?? '',
|
|
33
|
+
tenantId: ctx.tenantId,
|
|
34
|
+
organizationId: ctx.organizationId || null,
|
|
35
|
+
},
|
|
36
|
+
input,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { OpenApiResponseDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
|
+
|
|
4
|
+
/** Error body every CPQ quote route returns (`{ error, ... }`). */
|
|
5
|
+
export const cpqErrorResponseSchema = z.object({ error: z.string() }).passthrough()
|
|
6
|
+
|
|
7
|
+
export function cpqErrorDoc(status: number, description: string): OpenApiResponseDoc {
|
|
8
|
+
return { status, description, schema: cpqErrorResponseSchema }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const cpqQuoteIdPathParams = z.object({ quoteId: z.string().uuid() })
|
|
12
|
+
|
|
13
|
+
/** Shared error docs for quote writes guarded by mutation guards. */
|
|
14
|
+
export const CPQ_GUARDED_WRITE_ERRORS: OpenApiResponseDoc[] = [
|
|
15
|
+
cpqErrorDoc(400, 'Validation error'),
|
|
16
|
+
cpqErrorDoc(404, 'Quote or line not found'),
|
|
17
|
+
cpqErrorDoc(409, 'Quote is in a terminal status'),
|
|
18
|
+
cpqErrorDoc(422, 'Blocked by a mutation guard'),
|
|
19
|
+
]
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { cpqErrorDoc } from '../../_helpers/quoteRouteOpenApi'
|
|
2
5
|
import { resolveCpqRouteContext } from '../../context'
|
|
3
6
|
import { isOrderError } from '../../../services/cpqOrderService'
|
|
4
7
|
import type { DefaultCpqOrderService } from '../../../services/cpqOrderService'
|
|
@@ -26,3 +29,18 @@ export async function GET(req: Request, { params }: { params: Promise<{ orderId:
|
|
|
26
29
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
27
30
|
}
|
|
28
31
|
}
|
|
32
|
+
|
|
33
|
+
export const openApi: OpenApiRouteDoc = {
|
|
34
|
+
tag: 'CPQ Orders',
|
|
35
|
+
summary: 'CPQ order',
|
|
36
|
+
pathParams: z.object({ orderId: z.string().uuid() }),
|
|
37
|
+
methods: {
|
|
38
|
+
GET: {
|
|
39
|
+
summary: 'Get a CPQ order with its lines and pricing summary',
|
|
40
|
+
description:
|
|
41
|
+
'`pricingSummary` carries NRC / MRC totals plus the source quote\'s waterfall figures (`listNrcTotal`, `listMrcTotal`, `standard*Total`, `ruleDiscountTotal`, `manualDiscountTotal`, `dealValue`, `discountDepth`), computed from the line charges copied at conversion. ARC lines carry `arcSource` (the subscription item before the change).',
|
|
42
|
+
responses: [{ status: 200, description: 'The order' }],
|
|
43
|
+
errors: [cpqErrorDoc(401, 'Unauthorized'), cpqErrorDoc(404, 'Order not found')],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
@@ -4,6 +4,9 @@ import { resolveCpqRouteContext } from '../context'
|
|
|
4
4
|
import { cpqConvertQuoteToOrderSchema } from '../../data/validators'
|
|
5
5
|
import { isOrderError } from '../../services/cpqOrderService'
|
|
6
6
|
import type { DefaultCpqOrderService } from '../../services/cpqOrderService'
|
|
7
|
+
import { CPQ_QUOTE_RESOURCE_KIND, runCpqMutationGuards } from '../_helpers/quoteMutationGuard'
|
|
8
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
|
+
import { cpqErrorDoc } from '../_helpers/quoteRouteOpenApi'
|
|
7
10
|
|
|
8
11
|
export const metadata = {
|
|
9
12
|
POST: { requireAuth: true, requireFeatures: ['cpq.orders.manage'] },
|
|
@@ -18,8 +21,19 @@ export async function POST(req: Request) {
|
|
|
18
21
|
const body = cpqConvertQuoteToOrderSchema.parse(await req.json())
|
|
19
22
|
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
20
23
|
|
|
24
|
+
// Conversion turns a quote into an order — projects can block it (e.g.
|
|
25
|
+
// until an approval workflow has signed a discounted quote off).
|
|
26
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
27
|
+
resourceKind: CPQ_QUOTE_RESOURCE_KIND,
|
|
28
|
+
resourceId: body.quoteId,
|
|
29
|
+
operation: 'custom',
|
|
30
|
+
mutationPayload: { action: 'convert_to_order' },
|
|
31
|
+
})
|
|
32
|
+
if (!guard.ok) return guard.response
|
|
33
|
+
|
|
21
34
|
const service = ctx.container.resolve('cpqOrderService') as DefaultCpqOrderService
|
|
22
35
|
const result = await service.convertQuoteToOrder(body.quoteId, scope)
|
|
36
|
+
await guard.runAfterSuccess()
|
|
23
37
|
return NextResponse.json(result, { status: 201 })
|
|
24
38
|
} catch (err) {
|
|
25
39
|
return handleError(err, 'cpq/orders.POST')
|
|
@@ -66,7 +80,7 @@ function handleError(err: unknown, context: string) {
|
|
|
66
80
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
67
81
|
}
|
|
68
82
|
if (isOrderError(err)) {
|
|
69
|
-
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
83
|
+
return NextResponse.json({ error: err.message, ...err.details }, { status: err.status })
|
|
70
84
|
}
|
|
71
85
|
const errObj = err as Record<string, unknown>
|
|
72
86
|
if (typeof errObj?.status === 'number' && typeof errObj?.error === 'string') {
|
|
@@ -75,3 +89,39 @@ function handleError(err: unknown, context: string) {
|
|
|
75
89
|
console.error(`[${context}]`, err)
|
|
76
90
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
77
91
|
}
|
|
92
|
+
|
|
93
|
+
export const openApi: OpenApiRouteDoc = {
|
|
94
|
+
tag: 'CPQ Orders',
|
|
95
|
+
summary: 'CPQ orders',
|
|
96
|
+
methods: {
|
|
97
|
+
POST: {
|
|
98
|
+
summary: 'Convert a CPQ quote into an order',
|
|
99
|
+
description:
|
|
100
|
+
'Creates the sales order and CPQ order configuration from the quote. Refused for cancelled or rejected quotes; mutation guards (resource kind `cpq.quote`, action `convert_to_order`) run first.',
|
|
101
|
+
requestBody: { schema: cpqConvertQuoteToOrderSchema },
|
|
102
|
+
responses: [{ status: 201, description: 'Created order' }],
|
|
103
|
+
errors: [
|
|
104
|
+
cpqErrorDoc(400, 'Validation error'),
|
|
105
|
+
cpqErrorDoc(404, 'Quote not found'),
|
|
106
|
+
cpqErrorDoc(409, 'Quote cannot be converted in its status or is already converted'),
|
|
107
|
+
cpqErrorDoc(422, 'Quote has no lines or has invalid stored adjustments, or blocked by a mutation guard'),
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
GET: {
|
|
111
|
+
summary: 'List CPQ orders',
|
|
112
|
+
query: z.object({
|
|
113
|
+
customerId: z.string().uuid().optional(),
|
|
114
|
+
sourceQuoteId: z.string().uuid().optional(),
|
|
115
|
+
salesOrderId: z.string().uuid().optional(),
|
|
116
|
+
cpqStatus: z.string().optional(),
|
|
117
|
+
currencyCode: z.string().optional(),
|
|
118
|
+
search: z.string().optional(),
|
|
119
|
+
sortField: z.enum(['createdAt', 'updatedAt', 'cpqStatus', 'activatedAt', 'currencyCode']).optional(),
|
|
120
|
+
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
121
|
+
page: z.coerce.number().int().min(1).optional(),
|
|
122
|
+
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
|
123
|
+
}),
|
|
124
|
+
responses: [{ status: 200, description: 'Paged list of CPQ orders' }],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { resolveCpqRouteContext } from '../../../../../context'
|
|
4
|
+
import { isQuotingError } from '../../../../../../services/cpqQuotingService'
|
|
5
|
+
import { resolveQuotingService } from '../../../../../resolveQuotingService'
|
|
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 } from '../../../../../_helpers/quoteRouteOpenApi'
|
|
9
|
+
|
|
10
|
+
export const metadata = {
|
|
11
|
+
POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function POST(req: Request, { params }: { params: Promise<{ quoteId: string; lineId: string }> }) {
|
|
15
|
+
try {
|
|
16
|
+
const ctx = await resolveCpqRouteContext(req)
|
|
17
|
+
if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
18
|
+
|
|
19
|
+
const { quoteId, lineId } = await params
|
|
20
|
+
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
21
|
+
|
|
22
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
23
|
+
resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
|
|
24
|
+
resourceId: lineId,
|
|
25
|
+
operation: 'update',
|
|
26
|
+
mutationPayload: { quoteId, restore: true },
|
|
27
|
+
})
|
|
28
|
+
if (!guard.ok) return guard.response
|
|
29
|
+
|
|
30
|
+
const service = resolveQuotingService(ctx)
|
|
31
|
+
const result = await service.restoreQuoteItem(quoteId, lineId, scope)
|
|
32
|
+
await guard.runAfterSuccess()
|
|
33
|
+
return NextResponse.json(result)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (isQuotingError(err)) {
|
|
36
|
+
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
37
|
+
}
|
|
38
|
+
console.error('[cpq/quotes/[quoteId]/items/[lineId]/restore.POST]', err)
|
|
39
|
+
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const openApi: OpenApiRouteDoc = {
|
|
44
|
+
tag: 'CPQ Quotes',
|
|
45
|
+
summary: 'Restore a subscription item marked for removal',
|
|
46
|
+
pathParams: z.object({ quoteId: z.string().uuid(), lineId: z.string().uuid() }),
|
|
47
|
+
methods: {
|
|
48
|
+
POST: {
|
|
49
|
+
summary: 'Restore a removed subscription item on an Amend / Renew quote',
|
|
50
|
+
description:
|
|
51
|
+
'Turns a `cancel` line that mirrors a live subscription item back into a `modify` line, rebuilt from the item (configuration, quantity, stored price). Its cancelled mirrored components are restored with it. 409 when the line is not a removed item, its parent is still removed, or the item is no longer active. Mutation guards run first (resource kind `cpq.quote_line`, update).',
|
|
52
|
+
responses: [{ status: 200, description: 'Quote after the restore' }],
|
|
53
|
+
errors: CPQ_GUARDED_WRITE_ERRORS.filter((doc) => doc.status !== 400),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|