@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.
- package/CHANGELOG.md +44 -0
- package/MIGRATION.md +31 -1
- package/README.md +17 -12
- package/dist/modules/cpq/api/product-attributes/route.js +28 -53
- package/dist/modules/cpq/api/product-attributes/route.js.map +2 -2
- package/dist/modules/cpq/api/product-charges/route.js +12 -2
- package/dist/modules/cpq/api/product-charges/route.js.map +2 -2
- package/dist/modules/cpq/api/product-offerings/route.js +2 -0
- package/dist/modules/cpq/api/product-offerings/route.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +128 -59
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +11 -11
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +55 -1
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
- package/dist/modules/cpq/cli.js +58 -4
- package/dist/modules/cpq/cli.js.map +3 -3
- package/dist/modules/cpq/data/charge-pricing.js +12 -0
- package/dist/modules/cpq/data/charge-pricing.js.map +7 -0
- package/dist/modules/cpq/data/entities.js +3 -0
- package/dist/modules/cpq/data/entities.js.map +2 -2
- package/dist/modules/cpq/data/validators.js +36 -15
- package/dist/modules/cpq/data/validators.js.map +2 -2
- package/dist/modules/cpq/events.js +7 -0
- package/dist/modules/cpq/events.js.map +2 -2
- package/dist/modules/cpq/lib/seeds/demo.js +103 -0
- package/dist/modules/cpq/lib/seeds/demo.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js +28 -0
- package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js.map +7 -0
- package/dist/modules/cpq/services/cpqOrderService.js +130 -76
- package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
- package/dist/modules/cpq/services/cpqPricingService.js +53 -29
- package/dist/modules/cpq/services/cpqPricingService.js.map +3 -3
- package/dist/modules/cpq/services/types.js.map +2 -2
- package/package.json +4 -2
- package/src/modules/cpq/__integration__/TC-CPQ-012.spec.ts +34 -4
- package/src/modules/cpq/__tests__/package-isolation.test.ts +8 -3
- package/src/modules/cpq/api/product-attributes/__tests__/serializeAttribute.test.ts +62 -0
- package/src/modules/cpq/api/product-attributes/route.ts +34 -52
- package/src/modules/cpq/api/product-charges/route.ts +23 -2
- package/src/modules/cpq/api/product-offerings/route.ts +2 -0
- package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +151 -62
- package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -22
- package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +73 -1
- package/src/modules/cpq/cli.ts +83 -4
- package/src/modules/cpq/data/__tests__/charge-pricing.test.ts +49 -0
- package/src/modules/cpq/data/__tests__/validators.test.ts +60 -5
- package/src/modules/cpq/data/charge-pricing.ts +48 -0
- package/src/modules/cpq/data/entities.ts +10 -0
- package/src/modules/cpq/data/validators.ts +56 -26
- package/src/modules/cpq/events.ts +11 -3
- package/src/modules/cpq/lib/seeds/__tests__/demo.test.ts +78 -0
- package/src/modules/cpq/lib/seeds/demo.ts +145 -0
- package/src/modules/cpq/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.ts +57 -0
- package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +172 -1
- package/src/modules/cpq/services/cpqOrderService.ts +214 -103
- package/src/modules/cpq/services/cpqPricingService.ts +82 -34
- 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
|
-
//
|
|
245
|
-
//
|
|
246
|
-
const
|
|
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:
|
|
258
|
+
pricingMethod: model,
|
|
269
259
|
unitPrice: 0,
|
|
270
260
|
quantity: 1,
|
|
271
261
|
totalPrice: 0,
|
|
272
262
|
currencyCode: fallbackCurrency,
|
|
273
263
|
}
|
|
274
264
|
|
|
275
|
-
|
|
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 (
|
|
300
|
+
if (model === 'flat') {
|
|
300
301
|
return this.calculateFlat(base, charge, entries, table.dimensions, configuration, priceKey)
|
|
301
302
|
}
|
|
302
|
-
if (
|
|
303
|
+
if (model === 'per_unit') {
|
|
303
304
|
return this.calculatePerUnit(base, charge, entries, table.dimensions, configuration, priceKey)
|
|
304
305
|
}
|
|
305
|
-
if (
|
|
306
|
-
return this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|