@dainamite/cpq 0.4.0 → 0.5.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/MIGRATION.md +27 -0
  3. package/dist/modules/cpq/api/_helpers/handleArcError.js +2 -2
  4. package/dist/modules/cpq/api/_helpers/handleArcError.js.map +2 -2
  5. package/dist/modules/cpq/api/orders/[orderId]/activate/route.js +2 -2
  6. package/dist/modules/cpq/api/orders/[orderId]/activate/route.js.map +2 -2
  7. package/dist/modules/cpq/api/orders/[orderId]/route.js +2 -2
  8. package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
  9. package/dist/modules/cpq/api/orders/[orderId]/status/route.js +2 -2
  10. package/dist/modules/cpq/api/orders/[orderId]/status/route.js.map +2 -2
  11. package/dist/modules/cpq/api/orders/route.js +2 -2
  12. package/dist/modules/cpq/api/orders/route.js.map +2 -2
  13. package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js +2 -2
  14. package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js.map +2 -2
  15. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +3 -3
  16. package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
  17. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +2 -2
  18. package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
  19. package/dist/modules/cpq/api/quotes/[quoteId]/route.js +2 -2
  20. package/dist/modules/cpq/api/quotes/[quoteId]/route.js.map +2 -2
  21. package/dist/modules/cpq/api/quotes/route.js +2 -2
  22. package/dist/modules/cpq/api/quotes/route.js.map +2 -2
  23. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +1 -1
  24. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +1 -1
  25. package/dist/modules/cpq/backend/cpq/offerings/page.js.map +1 -1
  26. package/dist/modules/cpq/backend/cpq/orders/page.js.map +1 -1
  27. package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +1 -1
  28. package/dist/modules/cpq/backend/cpq/pricing/page.js.map +1 -1
  29. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +2 -2
  30. package/dist/modules/cpq/backend/cpq/quotes/page.js.map +1 -1
  31. package/dist/modules/cpq/backend/cpq/specifications/page.js.map +1 -1
  32. package/dist/modules/cpq/backend/cpq/wizards/page.js.map +1 -1
  33. package/dist/modules/cpq/components/CpqListView.js.map +2 -2
  34. package/dist/modules/cpq/services/cpqOrderService.js +9 -2
  35. package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
  36. package/dist/modules/cpq/services/cpqQuotingService.js +9 -2
  37. package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
  38. package/package.json +9 -9
  39. package/src/modules/cpq/__integration__/TC-CPQ-002.spec.ts +8 -5
  40. package/src/modules/cpq/api/_helpers/handleArcError.ts +12 -12
  41. package/src/modules/cpq/api/orders/[orderId]/activate/route.ts +2 -2
  42. package/src/modules/cpq/api/orders/[orderId]/route.ts +2 -2
  43. package/src/modules/cpq/api/orders/[orderId]/status/route.ts +2 -2
  44. package/src/modules/cpq/api/orders/route.ts +2 -2
  45. package/src/modules/cpq/api/quotes/[quoteId]/clone/route.ts +2 -2
  46. package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +3 -3
  47. package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +2 -2
  48. package/src/modules/cpq/api/quotes/[quoteId]/route.ts +2 -2
  49. package/src/modules/cpq/api/quotes/route.ts +2 -2
  50. package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +1 -1
  51. package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +1 -1
  52. package/src/modules/cpq/backend/cpq/offerings/page.tsx +1 -1
  53. package/src/modules/cpq/backend/cpq/orders/page.tsx +1 -1
  54. package/src/modules/cpq/backend/cpq/price-rules/page.tsx +1 -1
  55. package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
  56. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +4 -3
  57. package/src/modules/cpq/backend/cpq/quotes/page.tsx +1 -1
  58. package/src/modules/cpq/backend/cpq/specifications/page.tsx +1 -1
  59. package/src/modules/cpq/backend/cpq/wizards/page.tsx +1 -1
  60. package/src/modules/cpq/components/CpqListView.tsx +2 -1
  61. package/src/modules/cpq/services/__tests__/errorBrands.test.ts +72 -0
  62. package/src/modules/cpq/services/cpqOrderService.ts +10 -0
  63. package/src/modules/cpq/services/cpqQuotingService.ts +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dainamite/cpq",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Dainamite CPQ — Configure, Price, Quote engine for Open Mercato (configurable products, multi-charge pricing tables, guided quoting, customer inventory, orders).",
5
5
  "type": "module",
6
6
  "main": "./dist/modules/cpq/index.js",
@@ -121,14 +121,14 @@
121
121
  }
122
122
  },
123
123
  "peerDependencies": {
124
- "@mikro-orm/core": "^7.1.5",
125
- "@mikro-orm/decorators": "^7.1.5",
126
- "@mikro-orm/postgresql": "^7.1.5",
127
- "@open-mercato/core": "^0.6.7",
128
- "@open-mercato/events": "^0.6.7",
129
- "@open-mercato/shared": "^0.6.7",
130
- "@open-mercato/ui": "^0.6.7",
131
- "@tanstack/react-table": "^8.0.0 || ^9.0.0",
124
+ "@mikro-orm/core": "^7.1.14",
125
+ "@mikro-orm/decorators": "^7.1.14",
126
+ "@mikro-orm/postgresql": "^7.1.14",
127
+ "@open-mercato/core": "^0.9.0",
128
+ "@open-mercato/events": "^0.9.0",
129
+ "@open-mercato/shared": "^0.9.0",
130
+ "@open-mercato/ui": "^0.9.0",
131
+ "@tanstack/react-table": "^9.0.0",
132
132
  "awilix": "^12.0.0",
133
133
  "bcryptjs": "^3.0.0",
134
134
  "lucide-react": "^0.400.0",
@@ -108,16 +108,19 @@ test.describe('TC-CPQ-002: Quote golden path (API)', () => {
108
108
  expect(res.status()).toBe(400)
109
109
  })
110
110
 
111
- test('rejects fetching an unknown quote (404 expected; prod build observed 500 — accept 4xx/5xx)', async ({ request }) => {
111
+ test('rejects fetching an unknown quote with 404, also after a page has rendered', async ({ request }) => {
112
+ // Render a page first. Pages and API routes bundle @dainamite/cpq
113
+ // separately while sharing one DI container, so once the page bootstrap
114
+ // has run the route catches a QuotingError from the other copy of the
115
+ // class. With `instanceof` that surfaced as a 500; the brand-based
116
+ // isQuotingError guard must keep it a 404.
117
+ await request.get('/login')
112
118
  const res = await apiRequest(
113
119
  request,
114
120
  'GET',
115
121
  '/api/cpq/quotes/00000000-0000-4000-8000-000000000000',
116
122
  { token, data: undefined },
117
123
  )
118
- // Semantically: request must be rejected. Dev mode returns 404 cleanly; prod
119
- // build can surface 500 from MikroORM-not-found exceptions. Both prove "rejected".
120
- expect(res.ok()).toBeFalsy()
121
- expect(res.status()).toBeGreaterThanOrEqual(400)
124
+ expect(res.status()).toBe(404)
122
125
  })
123
126
  })
@@ -1,18 +1,19 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { OptimisticLockError } from '@mikro-orm/core'
4
- import { QuotingError } from '../../services/cpqQuotingService'
4
+ import { isQuotingError } from '../../services/cpqQuotingService'
5
5
 
6
6
  /**
7
7
  * Shared error → JSON-response converter for ARC routes (XD-250).
8
8
  *
9
9
  * Handles three families:
10
10
  * 1. Zod validation errors → 400 with `details` array.
11
- * 2. Service-layer `QuotingError` (matched both via `instanceof` AND via
12
- * duck-typing on `name === 'QuotingError'`) → propagates `err.status`.
13
- * The duck-typing fallback exists because Turbopack hot-reload can
14
- * produce two distinct class instances for the same source file
15
- * across module boundaries, breaking `instanceof` checks at runtime.
11
+ * 2. Service-layer `QuotingError` (matched via its cross-realm brand,
12
+ * `isQuotingError`, with a duck-typed fallback below) → propagates
13
+ * `err.status`. `instanceof` is unreliable here: Next bundles this
14
+ * package once per server layer, so the route and the DI-resolved
15
+ * service can hold two distinct copies of the class — in production
16
+ * too, not only under hot-reload.
16
17
  * 3. cpqInventoryService throws plain `{ status, error }` objects →
17
18
  * propagate the embedded status.
18
19
  * 4. Anything else → 500 + console.error with the route context.
@@ -28,7 +29,7 @@ export function handleArcError(err: unknown, ctx: string): NextResponse {
28
29
  )
29
30
  }
30
31
 
31
- if (err instanceof QuotingError) {
32
+ if (isQuotingError(err)) {
32
33
  const body: Record<string, unknown> = { error: err.message }
33
34
  if (err.cpqStatus) body.cpqStatus = err.cpqStatus
34
35
  if (err.details) Object.assign(body, err.details)
@@ -52,11 +53,10 @@ export function handleArcError(err: unknown, ctx: string): NextResponse {
52
53
  )
53
54
  }
54
55
 
55
- // Duck-typed QuotingError (Turbopack instanceof drift) and inventory-service
56
- // plain throw shape `{ status, error }`. Both surface a numeric `status`.
57
- // Also propagates `details` (object) into response body — preserves the
58
- // structured payload (e.g. `existingQuoteId`) that the typed branch above
59
- // would have surfaced if instanceof had matched.
56
+ // cpqInventoryService's plain throw shape `{ status, error }` (QuotingError
57
+ // from any bundle copy is already handled by `isQuotingError` above).
58
+ // Propagates `details` (object) into the response body so structured
59
+ // payloads survive here too.
60
60
  if (
61
61
  err &&
62
62
  typeof err === 'object' &&
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { OptimisticLockError } from '@mikro-orm/core'
3
3
  import { resolveCpqRouteContext } from '../../../context'
4
- import { OrderError } from '../../../../services/cpqOrderService'
4
+ import { isOrderError } from '../../../../services/cpqOrderService'
5
5
  import type { DefaultCpqOrderService } from '../../../../services/cpqOrderService'
6
6
 
7
7
  export const metadata = {
@@ -20,7 +20,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ orderId
20
20
  const result = await service.activateOrder(orderId, scope)
21
21
  return NextResponse.json(result)
22
22
  } catch (err) {
23
- if (err instanceof OrderError) {
23
+ if (isOrderError(err)) {
24
24
  return NextResponse.json({ error: err.message }, { status: err.status })
25
25
  }
26
26
  // XD-250 ARC: optimistic-lock collision on subscription.version when two
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { resolveCpqRouteContext } from '../../context'
3
- import { OrderError } from '../../../services/cpqOrderService'
3
+ import { isOrderError } from '../../../services/cpqOrderService'
4
4
  import type { DefaultCpqOrderService } from '../../../services/cpqOrderService'
5
5
 
6
6
  export const metadata = {
@@ -19,7 +19,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ orderId:
19
19
  const result = await service.getOrder(orderId, scope)
20
20
  return NextResponse.json(result)
21
21
  } catch (err) {
22
- if (err instanceof OrderError) {
22
+ if (isOrderError(err)) {
23
23
  return NextResponse.json({ error: err.message }, { status: err.status })
24
24
  }
25
25
  console.error('[cpq/orders/[orderId].GET]', err)
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../../../context'
4
4
  import { cpqOrderStatusTransitionSchema } from '../../../../data/validators'
5
- import { OrderError } from '../../../../services/cpqOrderService'
5
+ import { isOrderError } from '../../../../services/cpqOrderService'
6
6
  import type { DefaultCpqOrderService } from '../../../../services/cpqOrderService'
7
7
 
8
8
  export const metadata = {
@@ -25,7 +25,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ orderId
25
25
  if (err instanceof z.ZodError) {
26
26
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
27
27
  }
28
- if (err instanceof OrderError) {
28
+ if (isOrderError(err)) {
29
29
  return NextResponse.json({ error: err.message }, { status: err.status })
30
30
  }
31
31
  const errObj = err as Record<string, unknown>
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../context'
4
4
  import { cpqConvertQuoteToOrderSchema } from '../../data/validators'
5
- import { OrderError } from '../../services/cpqOrderService'
5
+ import { isOrderError } from '../../services/cpqOrderService'
6
6
  import type { DefaultCpqOrderService } from '../../services/cpqOrderService'
7
7
 
8
8
  export const metadata = {
@@ -65,7 +65,7 @@ function handleError(err: unknown, context: string) {
65
65
  if (err instanceof z.ZodError) {
66
66
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
67
67
  }
68
- if (err instanceof OrderError) {
68
+ if (isOrderError(err)) {
69
69
  return NextResponse.json({ error: err.message }, { status: err.status })
70
70
  }
71
71
  const errObj = err as Record<string, unknown>
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { resolveCpqRouteContext } from '../../../context'
3
- import { QuotingError } from '../../../../services/cpqQuotingService'
3
+ import { isQuotingError } from '../../../../services/cpqQuotingService'
4
4
  import { resolveQuotingService } from '../../../resolveQuotingService'
5
5
 
6
6
  export const metadata = {
@@ -19,7 +19,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
19
19
  const result = await service.cloneQuote(quoteId, scope)
20
20
  return NextResponse.json(result, { status: 201 })
21
21
  } catch (err) {
22
- if (err instanceof QuotingError) {
22
+ if (isQuotingError(err)) {
23
23
  return NextResponse.json({ error: err.message }, { status: err.status })
24
24
  }
25
25
  console.error('[cpq/quotes/[quoteId]/clone.POST]', err)
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../../../../context'
4
4
  import { cpqUpdateQuoteItemSchema } from '../../../../../data/validators'
5
- import { QuotingError } from '../../../../../services/cpqQuotingService'
5
+ import { isQuotingError } from '../../../../../services/cpqQuotingService'
6
6
  import { resolveQuotingService } from '../../../../resolveQuotingService'
7
7
 
8
8
  export const metadata = {
@@ -26,7 +26,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ quoteId:
26
26
  if (err instanceof z.ZodError) {
27
27
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
28
28
  }
29
- if (err instanceof QuotingError) {
29
+ if (isQuotingError(err)) {
30
30
  return NextResponse.json({ error: err.message }, { status: err.status })
31
31
  }
32
32
  console.error('[cpq/quotes/[quoteId]/items/[lineId].PUT]', err)
@@ -46,7 +46,7 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ quote
46
46
  const result = await service.removeQuoteItem(quoteId, lineId, scope)
47
47
  return NextResponse.json(result)
48
48
  } catch (err) {
49
- if (err instanceof QuotingError) {
49
+ if (isQuotingError(err)) {
50
50
  return NextResponse.json({ error: err.message }, { status: err.status })
51
51
  }
52
52
  console.error('[cpq/quotes/[quoteId]/items/[lineId].DELETE]', err)
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../../../context'
4
4
  import { cpqRecalculateSchema } from '../../../../data/validators'
5
- import { QuotingError } from '../../../../services/cpqQuotingService'
5
+ import { isQuotingError } from '../../../../services/cpqQuotingService'
6
6
  import { resolveQuotingService } from '../../../resolveQuotingService'
7
7
 
8
8
  export const metadata = {
@@ -25,7 +25,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
25
25
  if (err instanceof z.ZodError) {
26
26
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
27
27
  }
28
- if (err instanceof QuotingError) {
28
+ if (isQuotingError(err)) {
29
29
  return NextResponse.json({ error: err.message }, { status: err.status })
30
30
  }
31
31
  console.error('[cpq/quotes/[quoteId]/recalculate.POST]', err)
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { resolveCpqRouteContext } from '../../context'
3
3
  import { cpqUpdateQuoteSchema } from '../../../data/validators'
4
- import { QuotingError } from '../../../services/cpqQuotingService'
4
+ import { isQuotingError } from '../../../services/cpqQuotingService'
5
5
  import { resolveQuotingService } from '../../resolveQuotingService'
6
6
  import { CpqQuoteConfiguration, CpqQuoteLineConfiguration } from '../../../data/entities'
7
7
  import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
@@ -25,7 +25,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ quoteId:
25
25
  const result = await service.getQuote(quoteId, scope)
26
26
  return NextResponse.json(result)
27
27
  } catch (err) {
28
- if (err instanceof QuotingError) {
28
+ if (isQuotingError(err)) {
29
29
  return NextResponse.json({ error: err.message }, { status: err.status })
30
30
  }
31
31
  console.error('[cpq/quotes/[quoteId].GET]', err)
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../context'
4
4
  import { cpqCreateQuoteSchema } from '../../data/validators'
5
- import { QuotingError } from '../../services/cpqQuotingService'
5
+ import { isQuotingError } from '../../services/cpqQuotingService'
6
6
  import { resolveQuotingService } from '../resolveQuotingService'
7
7
  import { CpqQuoteConfiguration } from '../../data/entities'
8
8
  import { SalesQuote } from '@open-mercato/core/modules/sales/data/entities'
@@ -104,7 +104,7 @@ function handleError(err: unknown, context: string) {
104
104
  if (err instanceof z.ZodError) {
105
105
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
106
106
  }
107
- if (err instanceof QuotingError) {
107
+ if (isQuotingError(err)) {
108
108
  const body: Record<string, unknown> = { error: err.message }
109
109
  if (err.cpqStatus) body.cpqStatus = err.cpqStatus
110
110
  return NextResponse.json(body, { status: err.status })
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useT } from '@open-mercato/shared/lib/i18n/context'
4
4
  import { useRouter } from 'next/navigation'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import { DataTable } from '@open-mercato/ui/backend/DataTable'
7
7
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
8
8
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useT } from '@open-mercato/shared/lib/i18n/context'
4
4
  import { useRouter } from 'next/navigation'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import { DataTable } from '@open-mercato/ui/backend/DataTable'
7
7
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
8
8
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useT } from '@open-mercato/shared/lib/i18n/context'
4
4
  import { useRouter } from 'next/navigation'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import { type BulkAction } from '@open-mercato/ui/backend/DataTable'
7
7
  import { Button } from '@open-mercato/ui/primitives/button'
8
8
  import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useRouter } from 'next/navigation'
4
4
  import { useT } from '@open-mercato/shared/lib/i18n/context'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
7
7
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
8
8
  import { Tag } from '@open-mercato/ui/primitives/tag'
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useRouter } from 'next/navigation'
4
4
  import { useT } from '@open-mercato/shared/lib/i18n/context'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Tag } from '@open-mercato/ui/primitives/tag'
8
8
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useT } from '@open-mercato/shared/lib/i18n/context'
4
4
  import { useRouter } from 'next/navigation'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Tag } from '@open-mercato/ui/primitives/tag'
8
8
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
@@ -850,9 +850,10 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
850
850
  <Alert variant="warning">
851
851
  <AlertTitle>Validation Issues</AlertTitle>
852
852
  <AlertDescription>
853
- {/* AlertDescription renders a <p>, so the error list MUST be
854
- stacked <span>s — `<ul>`/`<li>` inside `<p>` is invalid
855
- HTML and Next 16 surfaces it as a hydration error. */}
853
+ {/* Stacked <span>s rather than <ul>/<li>: before Open Mercato
854
+ 0.8 AlertDescription rendered a <p>, where a list is invalid
855
+ HTML and a Next 16 hydration error. It is a <div> now, so a
856
+ list would be fine; the spans are kept as harmless. */}
856
857
  <span className="text-xs space-y-0.5 block">
857
858
  {cpqQuote.validationResult.errors.map((e, i) => (
858
859
  <span key={i} className="block">— {e.message}</span>
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useRouter } from 'next/navigation'
4
4
  import { useT } from '@open-mercato/shared/lib/i18n/context'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Input } from '@open-mercato/ui/primitives/input'
8
8
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useT } from '@open-mercato/shared/lib/i18n/context'
4
4
  import { useRouter } from 'next/navigation'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
8
8
  import { Tag } from '@open-mercato/ui/primitives/tag'
@@ -2,7 +2,7 @@
2
2
  import * as React from 'react'
3
3
  import { useRouter } from 'next/navigation'
4
4
  import { useT } from '@open-mercato/shared/lib/i18n/context'
5
- import type { ColumnDef } from '@tanstack/react-table'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
6
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
7
7
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
8
8
  import { Tag } from '@open-mercato/ui/primitives/tag'
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
- import type { ColumnDef, SortingState } from '@tanstack/react-table'
3
+ import type { SortingState } from '@tanstack/react-table'
4
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
4
5
  import { DataTable, type BulkAction } from '@open-mercato/ui/backend/DataTable'
5
6
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
7
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
@@ -0,0 +1,72 @@
1
+ /**
2
+ * QuotingError / OrderError must be recognisable across duplicate copies of
3
+ * their module. In a production Next build the page layer and the route layer
4
+ * each bundle @dainamite/cpq, and the DI container is shared via globalThis,
5
+ * so a route handler can catch an error built from the *other* copy. With
6
+ * `instanceof` that silently turned a typed 404 into a 500.
7
+ *
8
+ * `jest.isolateModules` loads a second, independent copy of each module —
9
+ * the same condition the bundler creates.
10
+ */
11
+ import { QuotingError, isQuotingError } from '../cpqQuotingService'
12
+ import { OrderError, isOrderError } from '../cpqOrderService'
13
+
14
+ type QuotingModule = typeof import('../cpqQuotingService')
15
+ type OrderModule = typeof import('../cpqOrderService')
16
+
17
+ function loadIsolated<T>(load: () => T): T {
18
+ let mod: T | undefined
19
+ jest.isolateModules(() => {
20
+ mod = load()
21
+ })
22
+ if (!mod) throw new Error('[internal] isolated module failed to load')
23
+ return mod
24
+ }
25
+
26
+ describe('isQuotingError', () => {
27
+ it('recognises an error thrown by a separate copy of the module', () => {
28
+ const other = loadIsolated<QuotingModule>(() => require('../cpqQuotingService'))
29
+ const err = new other.QuotingError(404, 'Quote not found')
30
+
31
+ // The precondition the guard exists for: the class identities differ.
32
+ expect(other.QuotingError).not.toBe(QuotingError)
33
+ expect(err instanceof QuotingError).toBe(false)
34
+
35
+ expect(isQuotingError(err)).toBe(true)
36
+ if (isQuotingError(err)) expect(err.status).toBe(404)
37
+ })
38
+
39
+ it('accepts a same-copy error', () => {
40
+ expect(isQuotingError(new QuotingError(409, 'conflict'))).toBe(true)
41
+ })
42
+
43
+ it.each([
44
+ ['a plain Error', new Error('x')],
45
+ ['an OrderError', new OrderError(404, 'x')],
46
+ ['a look-alike with the same name and status', Object.assign(new Error('x'), { name: 'QuotingError', status: 404 })],
47
+ ['null', null],
48
+ ['a string', 'QuotingError'],
49
+ ])('rejects %s', (_label, value) => {
50
+ expect(isQuotingError(value)).toBe(false)
51
+ })
52
+ })
53
+
54
+ describe('isOrderError', () => {
55
+ it('recognises an error thrown by a separate copy of the module', () => {
56
+ const other = loadIsolated<OrderModule>(() => require('../cpqOrderService'))
57
+ const err = new other.OrderError(404, 'Order not found')
58
+
59
+ expect(other.OrderError).not.toBe(OrderError)
60
+ expect(err instanceof OrderError).toBe(false)
61
+
62
+ expect(isOrderError(err)).toBe(true)
63
+ })
64
+
65
+ it.each([
66
+ ['a plain Error', new Error('x')],
67
+ ['a QuotingError', new QuotingError(404, 'x')],
68
+ ['undefined', undefined],
69
+ ])('rejects %s', (_label, value) => {
70
+ expect(isOrderError(value)).toBe(false)
71
+ })
72
+ })
@@ -1045,7 +1045,13 @@ function pickArcSourceFromConfig(
1045
1045
 
1046
1046
  // ─── Error class ──────────────────────────────────────────────────
1047
1047
 
1048
+ // Cross-realm brand — see QUOTING_ERROR_BRAND in cpqQuotingService.ts for why
1049
+ // route handlers must not rely on `instanceof`. Use `isOrderError`.
1050
+ const ORDER_ERROR_BRAND = Symbol.for('@dainamite/cpq/OrderError')
1051
+
1048
1052
  export class OrderError extends Error {
1053
+ readonly [ORDER_ERROR_BRAND] = true
1054
+
1049
1055
  constructor(
1050
1056
  public status: number,
1051
1057
  message: string,
@@ -1055,6 +1061,10 @@ export class OrderError extends Error {
1055
1061
  }
1056
1062
  }
1057
1063
 
1064
+ export function isOrderError(err: unknown): err is OrderError {
1065
+ return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[ORDER_ERROR_BRAND] === true
1066
+ }
1067
+
1058
1068
  // ─── ARC helpers (XD-250) ─────────────────────────────────────────
1059
1069
 
1060
1070
  /**
@@ -1387,7 +1387,17 @@ function emptyPricingSummary(currencyCode: string): Record<string, unknown> {
1387
1387
 
1388
1388
  // ─── Error class ──────────────────────────────────────────────────
1389
1389
 
1390
+ // Cross-realm brand. Next bundles this package once per server layer (page
1391
+ // SSR vs route handler), and the DI container is shared through globalThis,
1392
+ // so a route handler can catch a QuotingError built from the *other* copy of
1393
+ // this class — `instanceof` is then false and a typed 404/409 turns into a
1394
+ // 500. `Symbol.for` resolves to the same symbol in every copy. Same approach
1395
+ // as `isCrudHttpError` in @open-mercato/shared. Use `isQuotingError`.
1396
+ const QUOTING_ERROR_BRAND = Symbol.for('@dainamite/cpq/QuotingError')
1397
+
1390
1398
  export class QuotingError extends Error {
1399
+ readonly [QUOTING_ERROR_BRAND] = true
1400
+
1391
1401
  constructor(
1392
1402
  public status: number,
1393
1403
  message: string,
@@ -1406,6 +1416,10 @@ export class QuotingError extends Error {
1406
1416
  }
1407
1417
  }
1408
1418
 
1419
+ export function isQuotingError(err: unknown): err is QuotingError {
1420
+ return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[QUOTING_ERROR_BRAND] === true
1421
+ }
1422
+
1409
1423
  // ─── ARC orchestration (XD-250) ────────────────────────────────────
1410
1424
  //
1411
1425
  // All ARC user-journey logic lives on the CPQ Quote module: