@dainamite/cpq 0.2.2 → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/MIGRATION.md +31 -1
  3. package/README.md +17 -12
  4. package/dist/modules/cpq/api/product-attributes/route.js +28 -53
  5. package/dist/modules/cpq/api/product-attributes/route.js.map +2 -2
  6. package/dist/modules/cpq/api/product-charges/route.js +12 -2
  7. package/dist/modules/cpq/api/product-charges/route.js.map +2 -2
  8. package/dist/modules/cpq/api/product-offerings/route.js +2 -0
  9. package/dist/modules/cpq/api/product-offerings/route.js.map +2 -2
  10. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +128 -59
  11. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
  12. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +11 -11
  13. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
  14. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +55 -1
  15. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
  16. package/dist/modules/cpq/cli.js +58 -4
  17. package/dist/modules/cpq/cli.js.map +3 -3
  18. package/dist/modules/cpq/data/charge-pricing.js +12 -0
  19. package/dist/modules/cpq/data/charge-pricing.js.map +7 -0
  20. package/dist/modules/cpq/data/entities.js +3 -0
  21. package/dist/modules/cpq/data/entities.js.map +2 -2
  22. package/dist/modules/cpq/data/validators.js +36 -15
  23. package/dist/modules/cpq/data/validators.js.map +2 -2
  24. package/dist/modules/cpq/events.js +7 -0
  25. package/dist/modules/cpq/events.js.map +2 -2
  26. package/dist/modules/cpq/lib/seeds/demo.js +103 -0
  27. package/dist/modules/cpq/lib/seeds/demo.js.map +7 -0
  28. package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js +28 -0
  29. package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js.map +7 -0
  30. package/dist/modules/cpq/services/cpqOrderService.js +130 -76
  31. package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
  32. package/dist/modules/cpq/services/cpqPricingService.js +53 -29
  33. package/dist/modules/cpq/services/cpqPricingService.js.map +3 -3
  34. package/dist/modules/cpq/services/types.js.map +2 -2
  35. package/package.json +4 -2
  36. package/src/modules/cpq/__integration__/TC-CPQ-012.spec.ts +34 -4
  37. package/src/modules/cpq/__tests__/package-isolation.test.ts +8 -3
  38. package/src/modules/cpq/api/product-attributes/__tests__/serializeAttribute.test.ts +62 -0
  39. package/src/modules/cpq/api/product-attributes/route.ts +34 -52
  40. package/src/modules/cpq/api/product-charges/route.ts +23 -2
  41. package/src/modules/cpq/api/product-offerings/route.ts +2 -0
  42. package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +151 -62
  43. package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -22
  44. package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +73 -1
  45. package/src/modules/cpq/cli.ts +83 -4
  46. package/src/modules/cpq/data/__tests__/charge-pricing.test.ts +49 -0
  47. package/src/modules/cpq/data/__tests__/validators.test.ts +60 -5
  48. package/src/modules/cpq/data/charge-pricing.ts +48 -0
  49. package/src/modules/cpq/data/entities.ts +10 -0
  50. package/src/modules/cpq/data/validators.ts +56 -26
  51. package/src/modules/cpq/events.ts +11 -3
  52. package/src/modules/cpq/lib/seeds/__tests__/demo.test.ts +78 -0
  53. package/src/modules/cpq/lib/seeds/demo.ts +145 -0
  54. package/src/modules/cpq/migrations/.snapshot-open-mercato.json +16 -0
  55. package/src/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.ts +57 -0
  56. package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +172 -1
  57. package/src/modules/cpq/services/cpqOrderService.ts +214 -103
  58. package/src/modules/cpq/services/cpqPricingService.ts +82 -34
  59. package/src/modules/cpq/services/types.ts +4 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/cpq/services/types.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n\n// \u2500\u2500\u2500 Shared types for CPQ Quoting v2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface TenantScope {\n organizationId: string\n tenantId: string\n}\n\nconst DEFAULT_CURRENCY = 'USD'\n\n/**\n * Returns the base currency code for a given tenant scope.\n * Falls back to 'USD' if no base currency is found.\n */\nexport async function getBaseCurrencyCode(\n em: EntityManager,\n scope: TenantScope,\n): Promise<string> {\n try {\n const { Currency } = await import(\n '@open-mercato/core/modules/currencies/data/entities'\n )\n const base = await em.findOne(Currency, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n isBase: true,\n deletedAt: null,\n })\n return base?.code ?? DEFAULT_CURRENCY\n } catch {\n return DEFAULT_CURRENCY\n }\n}\n\n// \u2500\u2500\u2500 Quote lifecycle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_STATUSES = [\n 'new',\n 'incomplete',\n 'ready',\n 'in_approval',\n 'pre_approved',\n 'approved',\n 'with_customer',\n 'accepted',\n 'rejected',\n 'cancelled',\n] as const\n\nexport type CpqStatus = (typeof CPQ_STATUSES)[number]\n\nexport const TERMINAL_STATUSES: CpqStatus[] = ['accepted', 'rejected', 'cancelled']\n\n// Per product decision: the CPQ status path is operator-driven and can\n// jump between any pair of statuses. Self-transitions are excluded\n// because they're no-ops. ARC-specific guards (`validateArcQuote`,\n// concurrent-ARC checks) still run inside `transitionStatus` and are\n// the real safety net \u2014 this table only enforces the \"no self loop\"\n// invariant.\nexport const ALLOWED_TRANSITIONS: Record<CpqStatus, CpqStatus[]> = Object.fromEntries(\n CPQ_STATUSES.map((from) => [from, CPQ_STATUSES.filter((to) => to !== from)]),\n) as Record<CpqStatus, CpqStatus[]>\n\n// \u2500\u2500\u2500 Service input/output types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreateQuoteInput {\n customerId: string\n quoteId?: string // Link to existing SalesQuote instead of creating a new one\n dealId?: string\n currencyCode?: string\n quoteContext?: Record<string, string | number | boolean>\n}\n\nexport interface QuoteItemInput {\n offeringId?: string\n productId?: string\n configuration: Record<string, unknown>\n quantity?: number\n usageEstimates?: Array<{ chargeCode: string; estimatedQuantity: number }>\n action?: 'add' | 'modify' | 'cancel'\n parentLineId?: string\n startDate?: string\n termMonths?: number\n endDate?: string\n /** XD-250 ARC: target subscription this line acts on. Auto-inherited from\n * the quote's single attached target when omitted. Required when the ARC\n * quote has multiple non-merge targets (operator must pick). */\n targetSubscriptionId?: string | null\n}\n\nexport interface ValidationError {\n type?: 'attribute' | 'relationship'\n attribute?: string\n relationshipType?: string\n sourceSpec?: string\n targetSpec?: string\n message: string\n}\n\n/** XD-250 ARC: snapshot of the source subscription item's *current* state.\n * Surfaced on quote / order lines so the operator can compare \"before\" (this\n * snapshot) vs \"after\" (the line's current configuration). Null for lines\n * that aren't ARC-mirrored (e.g. a fresh `add` line on an amend quote). */\nexport interface ArcLineSource {\n subscriptionItemId: string\n name: string\n mrcAmount: number\n nrcAmount: number\n quantity: number\n}\n\nexport interface QuoteLineResult {\n lineId: string\n offeringId: string | null\n offeringName: string\n offeringType: string | null\n productId: string | null\n action: string\n parentLineId: string | null\n quantity: number\n configuration: Record<string, unknown>\n startDate: string | null\n termMonths: number | null\n endDate: string | null\n nrcTotal: number\n mrcTotal: number\n usageEstimates: Array<{ chargeCode: string; estimatedQuantity: number; estimatedTotal: number }> | null\n usageTotalEstimated: number | null\n charges: ResolvedCharge[]\n isConfigured: boolean\n validationErrors: ValidationError[] | null\n arcSource: ArcLineSource | null\n /** XD-250 multi-target ARC: which target subscription this line acts on. */\n targetSubscriptionId: string | null\n}\n\nexport interface PricingSummary {\n nrcTotal: number\n mrcTotal: number\n usageCharges: Array<{\n chargeCode: string\n chargeName: string\n unitPrice: number\n note: string | null\n }>\n usageTotalEstimated: number | null\n discountTotal: number\n surchargeTotal: number\n currencyCode: string\n}\n\nexport interface QuoteResult {\n id: string\n quoteId: string\n quoteNumber: string\n customerId: string\n /** Resolved customer display name (CustomerEntity.displayName). */\n customerName: string | null\n cpqStatus: string\n version: number\n parentQuoteId: string | null\n currencyCode: string\n /**\n * Set when this quote has been converted to a CPQ order. UI uses this\n * to lock editing and surface a \"Go to order\" entry point instead of\n * the Convert button. Backed by `CpqOrderConfiguration.sourceQuoteId`.\n */\n convertedOrderId: string | null\n validationResult: {\n valid: boolean\n errors: ValidationError[]\n }\n pricingSummary: PricingSummary\n lines: QuoteLineResult[]\n}\n\n// \u2500\u2500\u2500 Resolved charge (extended for v2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface RuleAdjustment {\n ruleCode: string\n ruleName: string\n ruleType: string\n value: number\n unitPriceBefore: number\n unitPriceAfter: number\n delta: number\n}\n\nexport interface TierBreakdown {\n tiers: Array<{\n tier: number\n from: number\n to: number\n pricePerUnit: number\n quantity: number\n subtotal: number\n }>\n}\n\nexport interface ResolvedCharge {\n chargeCode: string\n chargeName: string\n chargeType: 'nrc' | 'mrc' | 'usage'\n pricingMethod: 'flat' | 'tiered' | 'per_unit'\n unitPrice: number\n quantity: number | null\n totalPrice: number | null\n currencyCode: string\n breakdown?: TierBreakdown | null\n note?: string | null\n adjustments?: RuleAdjustment[] | null\n}\n\n// \u2500\u2500\u2500 Inventory status constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const INVENTORY_SUBSCRIPTION_STATUSES = [\n 'pending',\n 'active',\n 'suspended',\n 'terminated',\n 'expired',\n 'superseded',\n] as const\nexport type InventorySubscriptionStatus = (typeof INVENTORY_SUBSCRIPTION_STATUSES)[number]\n\nexport const INVENTORY_SUBSCRIPTION_TRANSITIONS: Record<InventorySubscriptionStatus, InventorySubscriptionStatus[]> = {\n pending: ['active', 'terminated'],\n active: ['suspended', 'terminated', 'expired', 'superseded'],\n suspended: ['active', 'terminated', 'superseded'],\n terminated: [],\n expired: [],\n superseded: [],\n}\n\nexport const INVENTORY_SUBSCRIPTION_ITEM_STATUSES = [\n 'pending',\n 'active',\n 'suspended',\n 'terminated',\n 'expired',\n 'superseded',\n] as const\nexport type InventorySubscriptionItemStatus = (typeof INVENTORY_SUBSCRIPTION_ITEM_STATUSES)[number]\n\nexport const INVENTORY_ASSET_STATUSES = ['pending', 'delivered', 'active', 'returned', 'cancelled'] as const\nexport type InventoryAssetStatus = (typeof INVENTORY_ASSET_STATUSES)[number]\n\nexport const INVENTORY_ASSET_TRANSITIONS: Record<InventoryAssetStatus, InventoryAssetStatus[]> = {\n pending: ['delivered', 'active', 'cancelled'],\n delivered: ['active', 'returned'],\n active: ['returned'],\n returned: [],\n cancelled: [],\n}\n\n// \u2500\u2500\u2500 Order status constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_ORDER_STATUSES = ['draft', 'pending_activation', 'active', 'cancelled', 'fulfilled'] as const\nexport type CpqOrderStatus = (typeof CPQ_ORDER_STATUSES)[number]\n\nexport const CPQ_ORDER_TRANSITIONS: Record<CpqOrderStatus, CpqOrderStatus[]> = {\n draft: ['pending_activation', 'active', 'cancelled'],\n pending_activation: ['active', 'cancelled'],\n active: ['fulfilled', 'cancelled'],\n cancelled: [],\n fulfilled: [],\n}\n\n// \u2500\u2500\u2500 ARC (Amend / Renew / Cancel) constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_QUOTE_TYPES = ['new', 'amend', 'renew', 'cancel'] as const\nexport type CpqQuoteType = (typeof CPQ_QUOTE_TYPES)[number]\n\nexport const ARC_QUOTE_TYPES: ReadonlyArray<Exclude<CpqQuoteType, 'new'>> = ['amend', 'renew', 'cancel']\n\nexport const CHANGE_LOG_TYPES = [\n 'created',\n 'amend',\n 'renew',\n 'cancel',\n 'merge-result',\n 'merge-source',\n] as const\nexport type ChangeLogType = (typeof CHANGE_LOG_TYPES)[number]\n\nexport const MERGE_ACTIONS = ['standalone', 'absorb'] as const\nexport type MergeAction = (typeof MERGE_ACTIONS)[number]\n\nexport const ARC_REASON_CODES = [\n 'upgrade',\n 'downgrade',\n 'config-change',\n 'price-adjustment',\n 'term-extension',\n 'term-reduction',\n 'consolidation',\n 'customer-request',\n 'non-payment',\n 'contract-breach',\n 'other',\n] as const\nexport type ArcReasonCode = (typeof ARC_REASON_CODES)[number]\n"],
5
- "mappings": "AASA,MAAM,mBAAmB;AAMzB,eAAsB,oBACpB,IACA,OACiB;AACjB,MAAI;AACF,UAAM,EAAE,SAAS,IAAI,MAAM,OACzB,qDACF;AACA,UAAM,OAAO,MAAM,GAAG,QAAQ,UAAU;AAAA,MACtC,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,QAAQ;AAAA,MACR,WAAW;AAAA,IACb,CAAC;AACD,WAAO,MAAM,QAAQ;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAIO,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIO,MAAM,oBAAiC,CAAC,YAAY,YAAY,WAAW;AAQ3E,MAAM,sBAAsD,OAAO;AAAA,EACxE,aAAa,IAAI,CAAC,SAAS,CAAC,MAAM,aAAa,OAAO,CAAC,OAAO,OAAO,IAAI,CAAC,CAAC;AAC7E;AA0JO,MAAM,kCAAkC;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,qCAAyG;AAAA,EACpH,SAAS,CAAC,UAAU,YAAY;AAAA,EAChC,QAAQ,CAAC,aAAa,cAAc,WAAW,YAAY;AAAA,EAC3D,WAAW,CAAC,UAAU,cAAc,YAAY;AAAA,EAChD,YAAY,CAAC;AAAA,EACb,SAAS,CAAC;AAAA,EACV,YAAY,CAAC;AACf;AAEO,MAAM,uCAAuC;AAAA,EAClD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,2BAA2B,CAAC,WAAW,aAAa,UAAU,YAAY,WAAW;AAG3F,MAAM,8BAAoF;AAAA,EAC/F,SAAS,CAAC,aAAa,UAAU,WAAW;AAAA,EAC5C,WAAW,CAAC,UAAU,UAAU;AAAA,EAChC,QAAQ,CAAC,UAAU;AAAA,EACnB,UAAU,CAAC;AAAA,EACX,WAAW,CAAC;AACd;AAIO,MAAM,qBAAqB,CAAC,SAAS,sBAAsB,UAAU,aAAa,WAAW;AAG7F,MAAM,wBAAkE;AAAA,EAC7E,OAAO,CAAC,sBAAsB,UAAU,WAAW;AAAA,EACnD,oBAAoB,CAAC,UAAU,WAAW;AAAA,EAC1C,QAAQ,CAAC,aAAa,WAAW;AAAA,EACjC,WAAW,CAAC;AAAA,EACZ,WAAW,CAAC;AACd;AAIO,MAAM,kBAAkB,CAAC,OAAO,SAAS,SAAS,QAAQ;AAG1D,MAAM,kBAA+D,CAAC,SAAS,SAAS,QAAQ;AAEhG,MAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,gBAAgB,CAAC,cAAc,QAAQ;AAG7C,MAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n// Charge model / pricing source live in the data layer (no ORM/DI coupling).\nimport type { ChargeModel } from '../data/charge-pricing'\n\n// \u2500\u2500\u2500 Shared types for CPQ Quoting v2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface TenantScope {\n organizationId: string\n tenantId: string\n}\n\nconst DEFAULT_CURRENCY = 'USD'\n\n/**\n * Returns the base currency code for a given tenant scope.\n * Falls back to 'USD' if no base currency is found.\n */\nexport async function getBaseCurrencyCode(\n em: EntityManager,\n scope: TenantScope,\n): Promise<string> {\n try {\n const { Currency } = await import(\n '@open-mercato/core/modules/currencies/data/entities'\n )\n const base = await em.findOne(Currency, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n isBase: true,\n deletedAt: null,\n })\n return base?.code ?? DEFAULT_CURRENCY\n } catch {\n return DEFAULT_CURRENCY\n }\n}\n\n// \u2500\u2500\u2500 Quote lifecycle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_STATUSES = [\n 'new',\n 'incomplete',\n 'ready',\n 'in_approval',\n 'pre_approved',\n 'approved',\n 'with_customer',\n 'accepted',\n 'rejected',\n 'cancelled',\n] as const\n\nexport type CpqStatus = (typeof CPQ_STATUSES)[number]\n\nexport const TERMINAL_STATUSES: CpqStatus[] = ['accepted', 'rejected', 'cancelled']\n\n// Per product decision: the CPQ status path is operator-driven and can\n// jump between any pair of statuses. Self-transitions are excluded\n// because they're no-ops. ARC-specific guards (`validateArcQuote`,\n// concurrent-ARC checks) still run inside `transitionStatus` and are\n// the real safety net \u2014 this table only enforces the \"no self loop\"\n// invariant.\nexport const ALLOWED_TRANSITIONS: Record<CpqStatus, CpqStatus[]> = Object.fromEntries(\n CPQ_STATUSES.map((from) => [from, CPQ_STATUSES.filter((to) => to !== from)]),\n) as Record<CpqStatus, CpqStatus[]>\n\n// \u2500\u2500\u2500 Service input/output types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreateQuoteInput {\n customerId: string\n quoteId?: string // Link to existing SalesQuote instead of creating a new one\n dealId?: string\n currencyCode?: string\n quoteContext?: Record<string, string | number | boolean>\n}\n\nexport interface QuoteItemInput {\n offeringId?: string\n productId?: string\n configuration: Record<string, unknown>\n quantity?: number\n usageEstimates?: Array<{ chargeCode: string; estimatedQuantity: number }>\n action?: 'add' | 'modify' | 'cancel'\n parentLineId?: string\n startDate?: string\n termMonths?: number\n endDate?: string\n /** XD-250 ARC: target subscription this line acts on. Auto-inherited from\n * the quote's single attached target when omitted. Required when the ARC\n * quote has multiple non-merge targets (operator must pick). */\n targetSubscriptionId?: string | null\n}\n\nexport interface ValidationError {\n type?: 'attribute' | 'relationship'\n attribute?: string\n relationshipType?: string\n sourceSpec?: string\n targetSpec?: string\n message: string\n}\n\n/** XD-250 ARC: snapshot of the source subscription item's *current* state.\n * Surfaced on quote / order lines so the operator can compare \"before\" (this\n * snapshot) vs \"after\" (the line's current configuration). Null for lines\n * that aren't ARC-mirrored (e.g. a fresh `add` line on an amend quote). */\nexport interface ArcLineSource {\n subscriptionItemId: string\n name: string\n mrcAmount: number\n nrcAmount: number\n quantity: number\n}\n\nexport interface QuoteLineResult {\n lineId: string\n offeringId: string | null\n offeringName: string\n offeringType: string | null\n productId: string | null\n action: string\n parentLineId: string | null\n quantity: number\n configuration: Record<string, unknown>\n startDate: string | null\n termMonths: number | null\n endDate: string | null\n nrcTotal: number\n mrcTotal: number\n usageEstimates: Array<{ chargeCode: string; estimatedQuantity: number; estimatedTotal: number }> | null\n usageTotalEstimated: number | null\n charges: ResolvedCharge[]\n isConfigured: boolean\n validationErrors: ValidationError[] | null\n arcSource: ArcLineSource | null\n /** XD-250 multi-target ARC: which target subscription this line acts on. */\n targetSubscriptionId: string | null\n}\n\nexport interface PricingSummary {\n nrcTotal: number\n mrcTotal: number\n usageCharges: Array<{\n chargeCode: string\n chargeName: string\n unitPrice: number\n note: string | null\n }>\n usageTotalEstimated: number | null\n discountTotal: number\n surchargeTotal: number\n currencyCode: string\n}\n\nexport interface QuoteResult {\n id: string\n quoteId: string\n quoteNumber: string\n customerId: string\n /** Resolved customer display name (CustomerEntity.displayName). */\n customerName: string | null\n cpqStatus: string\n version: number\n parentQuoteId: string | null\n currencyCode: string\n /**\n * Set when this quote has been converted to a CPQ order. UI uses this\n * to lock editing and surface a \"Go to order\" entry point instead of\n * the Convert button. Backed by `CpqOrderConfiguration.sourceQuoteId`.\n */\n convertedOrderId: string | null\n validationResult: {\n valid: boolean\n errors: ValidationError[]\n }\n pricingSummary: PricingSummary\n lines: QuoteLineResult[]\n}\n\n// \u2500\u2500\u2500 Resolved charge (extended for v2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface RuleAdjustment {\n ruleCode: string\n ruleName: string\n ruleType: string\n value: number\n unitPriceBefore: number\n unitPriceAfter: number\n delta: number\n}\n\nexport interface TierBreakdown {\n tiers: Array<{\n tier: number\n from: number\n to: number\n pricePerUnit: number\n quantity: number\n subtotal: number\n }>\n}\n\nexport interface ResolvedCharge {\n chargeCode: string\n chargeName: string\n chargeType: 'nrc' | 'mrc' | 'usage'\n // Display axis \u2014 the charge model (flat | per_unit | volume | tiered).\n pricingMethod: ChargeModel\n unitPrice: number\n quantity: number | null\n totalPrice: number | null\n currencyCode: string\n breakdown?: TierBreakdown | null\n note?: string | null\n adjustments?: RuleAdjustment[] | null\n}\n\n// \u2500\u2500\u2500 Inventory status constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const INVENTORY_SUBSCRIPTION_STATUSES = [\n 'pending',\n 'active',\n 'suspended',\n 'terminated',\n 'expired',\n 'superseded',\n] as const\nexport type InventorySubscriptionStatus = (typeof INVENTORY_SUBSCRIPTION_STATUSES)[number]\n\nexport const INVENTORY_SUBSCRIPTION_TRANSITIONS: Record<InventorySubscriptionStatus, InventorySubscriptionStatus[]> = {\n pending: ['active', 'terminated'],\n active: ['suspended', 'terminated', 'expired', 'superseded'],\n suspended: ['active', 'terminated', 'superseded'],\n terminated: [],\n expired: [],\n superseded: [],\n}\n\nexport const INVENTORY_SUBSCRIPTION_ITEM_STATUSES = [\n 'pending',\n 'active',\n 'suspended',\n 'terminated',\n 'expired',\n 'superseded',\n] as const\nexport type InventorySubscriptionItemStatus = (typeof INVENTORY_SUBSCRIPTION_ITEM_STATUSES)[number]\n\nexport const INVENTORY_ASSET_STATUSES = ['pending', 'delivered', 'active', 'returned', 'cancelled'] as const\nexport type InventoryAssetStatus = (typeof INVENTORY_ASSET_STATUSES)[number]\n\nexport const INVENTORY_ASSET_TRANSITIONS: Record<InventoryAssetStatus, InventoryAssetStatus[]> = {\n pending: ['delivered', 'active', 'cancelled'],\n delivered: ['active', 'returned'],\n active: ['returned'],\n returned: [],\n cancelled: [],\n}\n\n// \u2500\u2500\u2500 Order status constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_ORDER_STATUSES = ['draft', 'pending_activation', 'active', 'cancelled', 'fulfilled'] as const\nexport type CpqOrderStatus = (typeof CPQ_ORDER_STATUSES)[number]\n\nexport const CPQ_ORDER_TRANSITIONS: Record<CpqOrderStatus, CpqOrderStatus[]> = {\n draft: ['pending_activation', 'active', 'cancelled'],\n pending_activation: ['active', 'cancelled'],\n active: ['fulfilled', 'cancelled'],\n cancelled: [],\n fulfilled: [],\n}\n\n// \u2500\u2500\u2500 ARC (Amend / Renew / Cancel) constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_QUOTE_TYPES = ['new', 'amend', 'renew', 'cancel'] as const\nexport type CpqQuoteType = (typeof CPQ_QUOTE_TYPES)[number]\n\nexport const ARC_QUOTE_TYPES: ReadonlyArray<Exclude<CpqQuoteType, 'new'>> = ['amend', 'renew', 'cancel']\n\nexport const CHANGE_LOG_TYPES = [\n 'created',\n 'amend',\n 'renew',\n 'cancel',\n 'merge-result',\n 'merge-source',\n] as const\nexport type ChangeLogType = (typeof CHANGE_LOG_TYPES)[number]\n\nexport const MERGE_ACTIONS = ['standalone', 'absorb'] as const\nexport type MergeAction = (typeof MERGE_ACTIONS)[number]\n\nexport const ARC_REASON_CODES = [\n 'upgrade',\n 'downgrade',\n 'config-change',\n 'price-adjustment',\n 'term-extension',\n 'term-reduction',\n 'consolidation',\n 'customer-request',\n 'non-payment',\n 'contract-breach',\n 'other',\n] as const\nexport type ArcReasonCode = (typeof ARC_REASON_CODES)[number]\n"],
5
+ "mappings": "AAWA,MAAM,mBAAmB;AAMzB,eAAsB,oBACpB,IACA,OACiB;AACjB,MAAI;AACF,UAAM,EAAE,SAAS,IAAI,MAAM,OACzB,qDACF;AACA,UAAM,OAAO,MAAM,GAAG,QAAQ,UAAU;AAAA,MACtC,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,QAAQ;AAAA,MACR,WAAW;AAAA,IACb,CAAC;AACD,WAAO,MAAM,QAAQ;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAIO,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIO,MAAM,oBAAiC,CAAC,YAAY,YAAY,WAAW;AAQ3E,MAAM,sBAAsD,OAAO;AAAA,EACxE,aAAa,IAAI,CAAC,SAAS,CAAC,MAAM,aAAa,OAAO,CAAC,OAAO,OAAO,IAAI,CAAC,CAAC;AAC7E;AA2JO,MAAM,kCAAkC;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,qCAAyG;AAAA,EACpH,SAAS,CAAC,UAAU,YAAY;AAAA,EAChC,QAAQ,CAAC,aAAa,cAAc,WAAW,YAAY;AAAA,EAC3D,WAAW,CAAC,UAAU,cAAc,YAAY;AAAA,EAChD,YAAY,CAAC;AAAA,EACb,SAAS,CAAC;AAAA,EACV,YAAY,CAAC;AACf;AAEO,MAAM,uCAAuC;AAAA,EAClD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,2BAA2B,CAAC,WAAW,aAAa,UAAU,YAAY,WAAW;AAG3F,MAAM,8BAAoF;AAAA,EAC/F,SAAS,CAAC,aAAa,UAAU,WAAW;AAAA,EAC5C,WAAW,CAAC,UAAU,UAAU;AAAA,EAChC,QAAQ,CAAC,UAAU;AAAA,EACnB,UAAU,CAAC;AAAA,EACX,WAAW,CAAC;AACd;AAIO,MAAM,qBAAqB,CAAC,SAAS,sBAAsB,UAAU,aAAa,WAAW;AAG7F,MAAM,wBAAkE;AAAA,EAC7E,OAAO,CAAC,sBAAsB,UAAU,WAAW;AAAA,EACnD,oBAAoB,CAAC,UAAU,WAAW;AAAA,EAC1C,QAAQ,CAAC,aAAa,WAAW;AAAA,EACjC,WAAW,CAAC;AAAA,EACZ,WAAW,CAAC;AACd;AAIO,MAAM,kBAAkB,CAAC,OAAO,SAAS,SAAS,QAAQ;AAG1D,MAAM,kBAA+D,CAAC,SAAS,SAAS,QAAQ;AAEhG,MAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,gBAAgB,CAAC,cAAc,QAAQ;AAG7C,MAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dainamite/cpq",
3
- "version": "0.2.2",
3
+ "version": "0.3.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",
@@ -16,6 +16,9 @@
16
16
  "watch": "node watch.mjs",
17
17
  "typecheck": "tsc --noEmit"
18
18
  },
19
+ "dependencies": {
20
+ "xlsx": "^0.18.0"
21
+ },
19
22
  "devDependencies": {
20
23
  "chokidar": "^4.0.3"
21
24
  },
@@ -132,7 +135,6 @@
132
135
  "next": "^16.0.0",
133
136
  "react": "^19.0.0",
134
137
  "react-dom": "^19.0.0",
135
- "xlsx": "^0.18.0",
136
138
  "zod": "^4.0.0"
137
139
  },
138
140
  "publishConfig": {
@@ -1,4 +1,4 @@
1
- import { test, expect } from '@playwright/test'
1
+ import { test, expect, type Locator } from '@playwright/test'
2
2
  import { login } from '@open-mercato/core/helpers/integration/auth'
3
3
 
4
4
  /**
@@ -24,7 +24,31 @@ import { login } from '@open-mercato/core/helpers/integration/auth'
24
24
  // is unambiguous *and* doesn't depend on accessibility-name timing.
25
25
  const pickerLocator = '[role="combobox"]'
26
26
 
27
+ /**
28
+ * Open the Radix Select and wait until its options have mounted.
29
+ *
30
+ * The single `trigger.click()` was the flake source in CI: Radix can
31
+ * swallow the first click (the trigger isn't interactive yet on a slow
32
+ * cold render) or open-then-close the menu under load, so the option list
33
+ * never mounts and the following `toBeVisible` times out. Retrying the open
34
+ * until a known option is visible makes the step deterministic.
35
+ */
36
+ async function openExpiringPicker(trigger: Locator, probe: Locator): Promise<void> {
37
+ // The inner timeout (4s) is comfortably longer than a normal option mount,
38
+ // so a menu that's merely slow to paint isn't mistaken for "didn't open"
39
+ // and re-clicked shut — the only re-click happens when the prior click was
40
+ // swallowed and the menu is actually closed. The 12s budget stays well
41
+ // under the 20s global test timeout so a genuine failure surfaces here
42
+ // (with a useful message) rather than as an opaque test timeout. No
43
+ // per-test timeout override (integration-tests SKILL.md).
44
+ await expect(async () => {
45
+ await trigger.click()
46
+ await expect(probe).toBeVisible({ timeout: 4_000 })
47
+ }).toPass({ timeout: 12_000 })
48
+ }
49
+
27
50
  test.describe('TC-CPQ-012: Inline expiring picker (UI)', () => {
51
+
28
52
  test('picker renders in title row with the 5 preset windows', async ({ page }) => {
29
53
  await login(page, 'admin')
30
54
  await page.goto('/backend/cpq/inventory', { waitUntil: 'domcontentloaded' })
@@ -37,7 +61,7 @@ test.describe('TC-CPQ-012: Inline expiring picker (UI)', () => {
37
61
 
38
62
  // Open the dropdown and verify the 5 canonical preset windows are
39
63
  // listed in the same order the operator's renewal-watch UX expects.
40
- await picker.click()
64
+ await openExpiringPicker(picker, page.getByRole('option', { name: /Expiring in 30 days/i }))
41
65
  for (const days of [7, 14, 30, 60, 90]) {
42
66
  await expect(
43
67
  page.getByRole('option', { name: new RegExp(`Expiring in ${days} days`, 'i') }),
@@ -51,8 +75,14 @@ test.describe('TC-CPQ-012: Inline expiring picker (UI)', () => {
51
75
 
52
76
  const trigger = page.locator(pickerLocator).first()
53
77
  await expect(trigger).toBeVisible({ timeout: 15_000 })
54
- await trigger.click()
55
- await page.getByRole('option', { name: /Expiring in 30 days/i }).click()
78
+
79
+ // Open the menu deterministically (retries the click until the option
80
+ // mounts — see `openExpiringPicker`), then force the click to bypass
81
+ // Radix's portal positioning layer that the default actionability check
82
+ // treats as obscuring the option; Radix's own onSelect still fires.
83
+ const option = page.getByRole('option', { name: /Expiring in 30 days/i })
84
+ await openExpiringPicker(trigger, option)
85
+ await option.click({ force: true })
56
86
 
57
87
  // Same URL — the picker writes into the in-page filter state, not a
58
88
  // route param. Picker now reflects the chosen window.
@@ -21,9 +21,14 @@ import { metadata } from '../index'
21
21
 
22
22
  const PACKAGE_ROOT = path.resolve(__dirname, '..')
23
23
 
24
- // Must mirror peerDependencies in packages/cpq/package.json (plus relatives + node builtins).
25
- // Adding a prefix here without adding it to peerDependencies = silent runtime failure for
26
- // consumers that don't happen to have the dep transitively.
24
+ // Must mirror peerDependencies + dependencies in packages/cpq/package.json
25
+ // (plus relative paths + node builtins). Adding a prefix here without adding
26
+ // it to one of those two = silent runtime failure for consumers that don't
27
+ // happen to have the dep transitively. Sibling framework packages
28
+ // (@open-mercato/*, react, mikro-orm, etc.) MUST stay as peerDependencies so
29
+ // yarn keeps a single shared instance; CPQ-internal leaf libs (xlsx) live in
30
+ // dependencies so a fresh `yarn add @dainamite/cpq` scaffold pulls them in
31
+ // without manual setup (XD-289).
27
32
  const ALLOWED_IMPORT_PREFIXES = [
28
33
  './',
29
34
  '../',
@@ -0,0 +1,62 @@
1
+ import { serializeAttribute } from '../route'
2
+ import type { CpqProductAttribute } from '../../../data/entities'
3
+
4
+ /**
5
+ * XD-292 regression: the create (POST) and update (PUT) responses used to omit
6
+ * `resolutionTime` (and several other fields), so a freshly created design-time
7
+ * attribute rendered as "run" in the spec detail table until a page refresh.
8
+ * serializeAttribute is now the single source of truth for the API shape, so
9
+ * these assertions guard every method that returns it.
10
+ */
11
+ function makeAttribute(overrides: Partial<CpqProductAttribute> = {}): CpqProductAttribute {
12
+ const now = new Date('2026-06-01T00:00:00.000Z')
13
+ return {
14
+ id: 'attr-1',
15
+ productId: 'prod-1',
16
+ specId: 'spec-1',
17
+ code: 'platform-subscription-tier',
18
+ name: 'Platform Subscription Tier',
19
+ attributeType: 'select',
20
+ resolutionTime: 'design_time',
21
+ options: [{ value: 'a' }],
22
+ constraints: null,
23
+ referenceEntity: null,
24
+ referenceFilter: null,
25
+ dependsOn: { attributeCode: 'foo' },
26
+ defaultValue: 'a',
27
+ helpText: 'pick a tier',
28
+ sortOrder: 0,
29
+ isRequired: true,
30
+ isActive: true,
31
+ createdAt: now,
32
+ updatedAt: now,
33
+ ...overrides,
34
+ } as CpqProductAttribute
35
+ }
36
+
37
+ describe('serializeAttribute', () => {
38
+ it('includes resolutionTime so design-time attributes do not render as "run"', () => {
39
+ const out = serializeAttribute(makeAttribute({ resolutionTime: 'design_time' }))
40
+ expect(out.resolutionTime).toBe('design_time')
41
+ })
42
+
43
+ it('preserves run_time resolution', () => {
44
+ const out = serializeAttribute(makeAttribute({ resolutionTime: 'run_time' }))
45
+ expect(out.resolutionTime).toBe('run_time')
46
+ })
47
+
48
+ it('exposes every field the spec detail UI reads', () => {
49
+ const out = serializeAttribute(makeAttribute())
50
+ // The fields that were previously missing from POST/PUT responses.
51
+ expect(out).toMatchObject({
52
+ id: 'attr-1',
53
+ specId: 'spec-1',
54
+ resolutionTime: 'design_time',
55
+ referenceEntity: null,
56
+ referenceFilter: null,
57
+ dependsOn: { attributeCode: 'foo' },
58
+ defaultValue: 'a',
59
+ helpText: 'pick a tier',
60
+ })
61
+ })
62
+ })
@@ -11,6 +11,37 @@ export const metadata = {
11
11
  DELETE: { requireAuth: true, requireFeatures: ['cpq.products.manage'] },
12
12
  }
13
13
 
14
+ /**
15
+ * Single source of truth for the attribute API shape. GET, POST and PUT MUST
16
+ * all return this exact projection so the client never sees a partial record.
17
+ * Omitting fields here (notably `resolutionTime`) caused XD-292: a freshly
18
+ * created design-time attribute rendered as "run" until a page refresh, since
19
+ * the create response lacked the field the table reads.
20
+ */
21
+ export function serializeAttribute(item: CpqProductAttribute) {
22
+ return {
23
+ id: item.id,
24
+ productId: item.productId,
25
+ specId: item.specId,
26
+ code: item.code,
27
+ name: item.name,
28
+ attributeType: item.attributeType,
29
+ resolutionTime: item.resolutionTime,
30
+ options: item.options,
31
+ constraints: item.constraints,
32
+ referenceEntity: item.referenceEntity,
33
+ referenceFilter: item.referenceFilter,
34
+ dependsOn: item.dependsOn,
35
+ defaultValue: item.defaultValue,
36
+ helpText: item.helpText,
37
+ sortOrder: item.sortOrder,
38
+ isRequired: item.isRequired,
39
+ isActive: item.isActive,
40
+ createdAt: item.createdAt,
41
+ updatedAt: item.updatedAt,
42
+ }
43
+ }
44
+
14
45
  export async function GET(req: Request) {
15
46
  try {
16
47
  const ctx = await resolveCpqRouteContext(req)
@@ -35,27 +66,7 @@ export async function GET(req: Request) {
35
66
  })
36
67
 
37
68
  return NextResponse.json({
38
- items: items.map((item) => ({
39
- id: item.id,
40
- productId: item.productId,
41
- specId: item.specId,
42
- code: item.code,
43
- name: item.name,
44
- attributeType: item.attributeType,
45
- resolutionTime: item.resolutionTime,
46
- options: item.options,
47
- constraints: item.constraints,
48
- referenceEntity: item.referenceEntity,
49
- referenceFilter: item.referenceFilter,
50
- dependsOn: item.dependsOn,
51
- defaultValue: item.defaultValue,
52
- helpText: item.helpText,
53
- sortOrder: item.sortOrder,
54
- isRequired: item.isRequired,
55
- isActive: item.isActive,
56
- createdAt: item.createdAt,
57
- updatedAt: item.updatedAt,
58
- })),
69
+ items: items.map(serializeAttribute),
59
70
  total,
60
71
  page,
61
72
  pageSize,
@@ -88,23 +99,7 @@ export async function POST(req: Request) {
88
99
  const entity = ctx.em.create(CpqProductAttribute, { ...body, productId, ...scope })
89
100
  await ctx.em.flush()
90
101
 
91
- return NextResponse.json(
92
- {
93
- id: entity.id,
94
- productId: entity.productId,
95
- code: entity.code,
96
- name: entity.name,
97
- attributeType: entity.attributeType,
98
- options: entity.options,
99
- constraints: entity.constraints,
100
- sortOrder: entity.sortOrder,
101
- isRequired: entity.isRequired,
102
- isActive: entity.isActive,
103
- createdAt: entity.createdAt,
104
- updatedAt: entity.updatedAt,
105
- },
106
- { status: 201 },
107
- )
102
+ return NextResponse.json(serializeAttribute(entity), { status: 201 })
108
103
  } catch (err) {
109
104
  if (err instanceof z.ZodError) {
110
105
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
@@ -129,20 +124,7 @@ export async function PUT(req: Request) {
129
124
  ctx.em.assign(entity, updates)
130
125
  await ctx.em.flush()
131
126
 
132
- return NextResponse.json({
133
- id: entity.id,
134
- productId: entity.productId,
135
- code: entity.code,
136
- name: entity.name,
137
- attributeType: entity.attributeType,
138
- options: entity.options,
139
- constraints: entity.constraints,
140
- sortOrder: entity.sortOrder,
141
- isRequired: entity.isRequired,
142
- isActive: entity.isActive,
143
- createdAt: entity.createdAt,
144
- updatedAt: entity.updatedAt,
145
- })
127
+ return NextResponse.json(serializeAttribute(entity))
146
128
  } catch (err) {
147
129
  if (err instanceof z.ZodError) {
148
130
  return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
  import { resolveCpqRouteContext } from '../context'
4
4
  import { CpqProductCharge, CpqProductOffering, CpqProductSpecification } from '../../data/entities'
5
5
  import { cpqProductChargeCreateSchema, cpqProductChargeUpdateSchema } from '../../data/validators'
6
+ import { normalizeChargePricing } from '../../data/charge-pricing'
6
7
 
7
8
  export const metadata = {
8
9
  GET: { requireAuth: true, requireFeatures: ['cpq.products.view'] },
@@ -43,6 +44,7 @@ export async function GET(req: Request) {
43
44
  name: item.name,
44
45
  description: item.description,
45
46
  chargeType: item.chargeType,
47
+ chargeModel: item.chargeModel,
46
48
  pricingMethod: item.pricingMethod,
47
49
  pricingTableId: item.pricingTableId,
48
50
  priceColumnKey: item.priceColumnKey,
@@ -87,7 +89,10 @@ export async function POST(req: Request) {
87
89
  return NextResponse.json({ error: 'productId or offeringId with valid offering required' }, { status: 400 })
88
90
  }
89
91
 
90
- const entity = ctx.em.create(CpqProductCharge, { ...body, productId, ...scope })
92
+ // Persist the canonical split shape regardless of whether the client sent
93
+ // the new fields or a legacy combined pricingMethod.
94
+ const { model, source } = normalizeChargePricing(body)
95
+ const entity = ctx.em.create(CpqProductCharge, { ...body, chargeModel: model, pricingMethod: source, productId, ...scope })
91
96
  await ctx.em.flush()
92
97
 
93
98
  return NextResponse.json(
@@ -97,6 +102,7 @@ export async function POST(req: Request) {
97
102
  code: entity.code,
98
103
  name: entity.name,
99
104
  chargeType: entity.chargeType,
105
+ chargeModel: entity.chargeModel,
100
106
  pricingMethod: entity.pricingMethod,
101
107
  pricingTableId: entity.pricingTableId,
102
108
  priceColumnKey: entity.priceColumnKey,
@@ -130,7 +136,21 @@ export async function PUT(req: Request) {
130
136
  const entity = await ctx.em.findOne(CpqProductCharge, { id, ...scope })
131
137
  if (!entity) return NextResponse.json({ error: 'Not found' }, { status: 404 })
132
138
 
133
- ctx.em.assign(entity, updates)
139
+ // Re-derive the canonical split shape when pricing fields are being changed,
140
+ // and clear the opposite axis' fields so a partial PATCH can't leave a
141
+ // stale pricingTableId on a fixed charge (or a stale fixedPrice on a table
142
+ // charge) that the patch-only validator never sees.
143
+ const normalizedUpdates =
144
+ updates.pricingMethod !== undefined || updates.chargeModel !== undefined
145
+ ? (() => {
146
+ const { model, source } = normalizeChargePricing({ ...entity, ...updates })
147
+ const sideCleared = source === 'fixed'
148
+ ? { pricingTableId: null, priceColumnKey: null }
149
+ : { fixedPrice: null }
150
+ return { ...updates, chargeModel: model, pricingMethod: source, ...sideCleared }
151
+ })()
152
+ : updates
153
+ ctx.em.assign(entity, normalizedUpdates)
134
154
  await ctx.em.flush()
135
155
 
136
156
  return NextResponse.json({
@@ -139,6 +159,7 @@ export async function PUT(req: Request) {
139
159
  code: entity.code,
140
160
  name: entity.name,
141
161
  chargeType: entity.chargeType,
162
+ chargeModel: entity.chargeModel,
142
163
  pricingMethod: entity.pricingMethod,
143
164
  pricingTableId: entity.pricingTableId,
144
165
  priceColumnKey: entity.priceColumnKey,
@@ -91,6 +91,7 @@ export async function GET(req: Request) {
91
91
  name: c.name,
92
92
  description: c.description,
93
93
  chargeType: c.chargeType,
94
+ chargeModel: c.chargeModel,
94
95
  pricingMethod: c.pricingMethod,
95
96
  pricingTableId: c.pricingTableId,
96
97
  priceColumnKey: c.priceColumnKey,
@@ -165,6 +166,7 @@ export async function GET(req: Request) {
165
166
  name: c.name,
166
167
  description: c.description,
167
168
  chargeType: c.chargeType,
169
+ chargeModel: c.chargeModel,
168
170
  pricingMethod: c.pricingMethod,
169
171
  pricingTableId: c.pricingTableId,
170
172
  priceColumnKey: c.priceColumnKey,