@dainamite/cpq 0.2.2 → 0.4.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 +62 -0
  2. package/MIGRATION.md +34 -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 +11 -9
  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
@@ -2,6 +2,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { CpqProductCharge, CpqPricingTable, CpqPricingTableEntry, CpqPriceRule } from '../data/entities'
3
3
  import type { ResolvedCharge, RuleAdjustment, TierBreakdown } from './types'
4
4
  import { getBaseCurrencyCode } from './types'
5
+ import { normalizeChargePricing } from '../data/charge-pricing'
5
6
 
6
7
  export type { ResolvedCharge, RuleAdjustment, TierBreakdown }
7
8
 
@@ -39,20 +40,24 @@ function evaluateApplicability(
39
40
  return true
40
41
  }
41
42
 
43
+ function entryMatchesDimensions(
44
+ entry: CpqPricingTableEntry,
45
+ dimensions: Array<{ key: string }>,
46
+ configuration: Record<string, unknown>,
47
+ ): boolean {
48
+ return dimensions.every((dim) => {
49
+ const configVal = String(configuration[dim.key] ?? '')
50
+ const entryVal = String(entry.dimensionValues[dim.key] ?? '')
51
+ return configVal.toLowerCase() === entryVal.toLowerCase()
52
+ })
53
+ }
54
+
42
55
  function matchEntry(
43
56
  entries: CpqPricingTableEntry[],
44
57
  dimensions: Array<{ key: string }>,
45
58
  configuration: Record<string, unknown>,
46
59
  ): CpqPricingTableEntry | null {
47
- return (
48
- entries.find((entry) =>
49
- dimensions.every((dim) => {
50
- const configVal = String(configuration[dim.key] ?? '')
51
- const entryVal = String(entry.dimensionValues[dim.key] ?? '')
52
- return configVal.toLowerCase() === entryVal.toLowerCase()
53
- }),
54
- ) ?? null
55
- )
60
+ return entries.find((entry) => entryMatchesDimensions(entry, dimensions, configuration)) ?? null
56
61
  }
57
62
 
58
63
  export class DefaultCpqPricingService {
@@ -241,39 +246,35 @@ export class DefaultCpqPricingService {
241
246
  }): Promise<ResolvedCharge> {
242
247
  const { charge, configuration, tenantId, organizationId, currencyCode } = params
243
248
 
244
- // `fixed` is a legacy alias for `flat` — older seeds emit it directly
245
- // and ~30+ rows in prod still carry it. Treat them identically.
246
- const method = charge.pricingMethod === 'fixed' ? 'flat' : charge.pricingMethod
247
-
248
- // If charge has a fixed price and no pricing table, use fixed price directly
249
- if (charge.fixedPrice && !charge.pricingTableId) {
250
- const unitPrice = Number(charge.fixedPrice)
251
- return {
252
- chargeCode: charge.code,
253
- chargeName: charge.name,
254
- chargeType: charge.chargeType as ResolvedCharge['chargeType'],
255
- pricingMethod: method as ResolvedCharge['pricingMethod'],
256
- unitPrice,
257
- quantity: 1,
258
- totalPrice: unitPrice,
259
- currencyCode: charge.currencyCode ?? await getBaseCurrencyCode(this.em, { tenantId, organizationId }),
260
- }
261
- }
249
+ // Resolve the two axes (chargeModel × pricingSource), tolerating legacy
250
+ // rows that still carry a combined pricingMethod (flat | per_unit | tiered).
251
+ const { model, source } = normalizeChargePricing(charge)
262
252
 
263
253
  const fallbackCurrency = currencyCode ?? charge.currencyCode ?? await getBaseCurrencyCode(this.em, { tenantId, organizationId })
264
254
  const base: ResolvedCharge = {
265
255
  chargeCode: charge.code,
266
256
  chargeName: charge.name,
267
257
  chargeType: charge.chargeType as ResolvedCharge['chargeType'],
268
- pricingMethod: method as ResolvedCharge['pricingMethod'],
258
+ pricingMethod: model,
269
259
  unitPrice: 0,
270
260
  quantity: 1,
271
261
  totalPrice: 0,
272
262
  currencyCode: fallbackCurrency,
273
263
  }
274
264
 
275
- if (!charge.pricingTableId) return base
265
+ // ── Fixed price: unit price is the charge's own fixedPrice. Also the
266
+ // fallback when a table-sourced charge has no table configured yet. ──
267
+ if (source === 'fixed' || !charge.pricingTableId) {
268
+ const unitPrice = Number(charge.fixedPrice ?? 0)
269
+ if (model === 'per_unit') {
270
+ const quantity = Number(configuration[charge.quantityAttributeCode ?? ''] ?? 0)
271
+ return { ...base, unitPrice, quantity, totalPrice: unitPrice * quantity }
272
+ }
273
+ // flat (volume/tiered without a table degrade to a single flat price).
274
+ return { ...base, unitPrice, quantity: 1, totalPrice: unitPrice }
275
+ }
276
276
 
277
+ // ── Table lookup ──
277
278
  const table = await this.em.findOne(CpqPricingTable, {
278
279
  id: charge.pricingTableId,
279
280
  tenantId,
@@ -296,14 +297,17 @@ export class DefaultCpqPricingService {
296
297
 
297
298
  const priceKey = charge.priceColumnKey ?? ''
298
299
 
299
- if (method === 'flat') {
300
+ if (model === 'flat') {
300
301
  return this.calculateFlat(base, charge, entries, table.dimensions, configuration, priceKey)
301
302
  }
302
- if (method === 'per_unit') {
303
+ if (model === 'per_unit') {
303
304
  return this.calculatePerUnit(base, charge, entries, table.dimensions, configuration, priceKey)
304
305
  }
305
- if (method === 'tiered') {
306
- return this.calculateTiered(base, charge, entries, configuration, priceKey)
306
+ if (model === 'volume') {
307
+ return this.calculateVolume(base, charge, entries, table.dimensions, configuration, priceKey)
308
+ }
309
+ if (model === 'tiered') {
310
+ return this.calculateTiered(base, charge, entries, table.dimensions, configuration, priceKey)
307
311
  }
308
312
 
309
313
  return base
@@ -367,13 +371,20 @@ export class DefaultCpqPricingService {
367
371
  base: ResolvedCharge,
368
372
  charge: CpqProductCharge,
369
373
  entries: CpqPricingTableEntry[],
374
+ dimensions: Array<{ key: string }>,
370
375
  configuration: Record<string, unknown>,
371
376
  priceKey: string,
372
377
  ): ResolvedCharge {
373
378
  const quantity = Number(configuration[charge.quantityAttributeCode ?? ''] ?? 0)
374
379
  if (quantity <= 0) return { ...base, quantity: 0, totalPrice: 0 }
375
380
 
376
- const sorted = [...entries].sort((a, b) => (a.tierNumber ?? 0) - (b.tierNumber ?? 0))
381
+ // tiered must filter to the dimension-matched slice first; flat/per_unit do this via matchEntry.
382
+ const matched = entries.filter((entry) => entryMatchesDimensions(entry, dimensions, configuration))
383
+ // No row matches the configured dimensions → unpriced; fall back to base
384
+ // (quantity 1, total 0), consistent with flat/per_unit's null-match path.
385
+ if (matched.length === 0) return base
386
+
387
+ const sorted = [...matched].sort((a, b) => (a.tierNumber ?? 0) - (b.tierNumber ?? 0))
377
388
 
378
389
  let allocated = 0
379
390
  let total = 0
@@ -409,4 +420,41 @@ export class DefaultCpqPricingService {
409
420
  currencyCode: entryCurrency,
410
421
  }
411
422
  }
423
+
424
+ // Volume pricing: the WHOLE quantity is billed at the single rate of the tier
425
+ // whose range contains the total quantity — unlike tiered, which bills each
426
+ // slice at its own rate. Dimension-filtered first, like tiered.
427
+ private calculateVolume(
428
+ base: ResolvedCharge,
429
+ charge: CpqProductCharge,
430
+ entries: CpqPricingTableEntry[],
431
+ dimensions: Array<{ key: string }>,
432
+ configuration: Record<string, unknown>,
433
+ priceKey: string,
434
+ ): ResolvedCharge {
435
+ const quantity = Number(configuration[charge.quantityAttributeCode ?? ''] ?? 0)
436
+ if (quantity <= 0) return { ...base, quantity: 0, totalPrice: 0 }
437
+
438
+ const matched = entries.filter((entry) => entryMatchesDimensions(entry, dimensions, configuration))
439
+ if (matched.length === 0) return base
440
+
441
+ // Ranges are [rangeFrom, rangeTo]; rangeTo null = ∞. Fall back to the top
442
+ // tier if the quantity overflows every defined range.
443
+ const sorted = [...matched].sort((a, b) => (a.tierNumber ?? 0) - (b.tierNumber ?? 0))
444
+ const entry =
445
+ sorted.find((e) => {
446
+ const from = e.rangeFrom != null ? Number(e.rangeFrom) : 0
447
+ const to = e.rangeTo != null ? Number(e.rangeTo) : Infinity
448
+ return quantity >= from && quantity <= to
449
+ }) ?? sorted[sorted.length - 1]
450
+
451
+ const unitPrice = entry.prices[priceKey] ?? 0
452
+ return {
453
+ ...base,
454
+ unitPrice,
455
+ quantity,
456
+ totalPrice: unitPrice * quantity,
457
+ currencyCode: entry.currencyCode,
458
+ }
459
+ }
412
460
  }
@@ -1,4 +1,6 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
+ // Charge model / pricing source live in the data layer (no ORM/DI coupling).
3
+ import type { ChargeModel } from '../data/charge-pricing'
2
4
 
3
5
  // ─── Shared types for CPQ Quoting v2 ─────────────────────────────
4
6
 
@@ -202,7 +204,8 @@ export interface ResolvedCharge {
202
204
  chargeCode: string
203
205
  chargeName: string
204
206
  chargeType: 'nrc' | 'mrc' | 'usage'
205
- pricingMethod: 'flat' | 'tiered' | 'per_unit'
207
+ // Display axis — the charge model (flat | per_unit | volume | tiered).
208
+ pricingMethod: ChargeModel
206
209
  unitPrice: number
207
210
  quantity: number | null
208
211
  totalPrice: number | null