@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
|
@@ -290,8 +290,10 @@ describe('cpqProductChargeCreateSchema', () => {
|
|
|
290
290
|
expect(result.error.issues.some((i) => i.path[0] === 'currencyCode')).toBe(true)
|
|
291
291
|
})
|
|
292
292
|
|
|
293
|
-
it('V-CHG-1: rejects
|
|
294
|
-
|
|
293
|
+
it('V-CHG-1: rejects a fixed price with a stray pricingTableId', () => {
|
|
294
|
+
// Explicit fixed source — a pricing table is forbidden. (Legacy `flat` is
|
|
295
|
+
// now inferred as a flat *table* lookup when a table is present.)
|
|
296
|
+
const result = cpqProductChargeCreateSchema.safeParse({ ...flatBase, pricingMethod: 'fixed', pricingTableId: VALID_UUID })
|
|
295
297
|
expect(result.success).toBe(false)
|
|
296
298
|
if (result.success) return
|
|
297
299
|
expect(result.error.issues.some((i) => i.path[0] === 'pricingTableId')).toBe(true)
|
|
@@ -335,11 +337,9 @@ describe('cpqProductChargeCreateSchema', () => {
|
|
|
335
337
|
})
|
|
336
338
|
|
|
337
339
|
// ─── Legacy `fixed` alias ──────────────────────────────────
|
|
338
|
-
it('
|
|
340
|
+
it('accepts the legacy `fixed` pricing method (route normalises to the split shape)', () => {
|
|
339
341
|
const result = cpqProductChargeCreateSchema.safeParse({ ...flatBase, pricingMethod: 'fixed' })
|
|
340
342
|
expect(result.success).toBe(true)
|
|
341
|
-
if (!result.success) return
|
|
342
|
-
expect(result.data.pricingMethod).toBe('flat')
|
|
343
343
|
})
|
|
344
344
|
|
|
345
345
|
it('legacy `fixed` still requires fixedPrice + currencyCode', () => {
|
|
@@ -347,6 +347,61 @@ describe('cpqProductChargeCreateSchema', () => {
|
|
|
347
347
|
const result = cpqProductChargeCreateSchema.safeParse({ ...partial, pricingMethod: 'fixed' })
|
|
348
348
|
expect(result.success).toBe(false)
|
|
349
349
|
})
|
|
350
|
+
|
|
351
|
+
// ─── XD-297: charge model × pricing method combinations ──────
|
|
352
|
+
it('rejects volume + fixed (volume requires table lookup)', () => {
|
|
353
|
+
const result = cpqProductChargeCreateSchema.safeParse({ ...flatBase, chargeModel: 'volume', pricingMethod: 'fixed' })
|
|
354
|
+
expect(result.success).toBe(false)
|
|
355
|
+
if (result.success) return
|
|
356
|
+
expect(result.error.issues.some((i) => i.path[0] === 'pricingMethod')).toBe(true)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('rejects tiered + fixed (tiered requires table lookup)', () => {
|
|
360
|
+
const result = cpqProductChargeCreateSchema.safeParse({ ...flatBase, chargeModel: 'tiered', pricingMethod: 'fixed' })
|
|
361
|
+
expect(result.success).toBe(false)
|
|
362
|
+
if (result.success) return
|
|
363
|
+
expect(result.error.issues.some((i) => i.path[0] === 'pricingMethod')).toBe(true)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('accepts per_unit + fixed with a quantity attribute', () => {
|
|
367
|
+
const result = cpqProductChargeCreateSchema.safeParse({
|
|
368
|
+
code: 'seat', name: 'Seat', chargeType: 'mrc',
|
|
369
|
+
chargeModel: 'per_unit', pricingMethod: 'fixed',
|
|
370
|
+
fixedPrice: '5', currencyCode: 'USD', quantityAttributeCode: 'seats',
|
|
371
|
+
})
|
|
372
|
+
expect(result.success).toBe(true)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('rejects per_unit + fixed without a quantity attribute', () => {
|
|
376
|
+
const result = cpqProductChargeCreateSchema.safeParse({
|
|
377
|
+
code: 'seat', name: 'Seat', chargeType: 'mrc',
|
|
378
|
+
chargeModel: 'per_unit', pricingMethod: 'fixed',
|
|
379
|
+
fixedPrice: '5', currencyCode: 'USD',
|
|
380
|
+
})
|
|
381
|
+
expect(result.success).toBe(false)
|
|
382
|
+
if (result.success) return
|
|
383
|
+
expect(result.error.issues.some((i) => i.path[0] === 'quantityAttributeCode')).toBe(true)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('accepts flat + table without a quantity attribute', () => {
|
|
387
|
+
const result = cpqProductChargeCreateSchema.safeParse({
|
|
388
|
+
code: 'plat', name: 'Platform', chargeType: 'mrc',
|
|
389
|
+
chargeModel: 'flat', pricingMethod: 'table',
|
|
390
|
+
pricingTableId: VALID_UUID, priceColumnKey: 'list',
|
|
391
|
+
})
|
|
392
|
+
expect(result.success).toBe(true)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('rejects flat + table with a stray quantity attribute', () => {
|
|
396
|
+
const result = cpqProductChargeCreateSchema.safeParse({
|
|
397
|
+
code: 'plat', name: 'Platform', chargeType: 'mrc',
|
|
398
|
+
chargeModel: 'flat', pricingMethod: 'table',
|
|
399
|
+
pricingTableId: VALID_UUID, priceColumnKey: 'list', quantityAttributeCode: 'seats',
|
|
400
|
+
})
|
|
401
|
+
expect(result.success).toBe(false)
|
|
402
|
+
if (result.success) return
|
|
403
|
+
expect(result.error.issues.some((i) => i.path[0] === 'quantityAttributeCode')).toBe(true)
|
|
404
|
+
})
|
|
350
405
|
})
|
|
351
406
|
|
|
352
407
|
describe('cpqPriceRuleCreateSchema', () => {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// XD-297: a charge's "shape" is two orthogonal axes:
|
|
2
|
+
// ChargeModel — how quantity drives the charge.
|
|
3
|
+
// PricingSource — where the unit price comes from.
|
|
4
|
+
// Pure, dependency-free helpers live in the data layer so validators, routes,
|
|
5
|
+
// the pricing service, seeds, and the UI can all share them without reaching
|
|
6
|
+
// across layers.
|
|
7
|
+
export type ChargeModel = 'flat' | 'per_unit' | 'volume' | 'tiered'
|
|
8
|
+
export type PricingSource = 'fixed' | 'table'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve a charge's (chargeModel, pricingSource) from either the new split
|
|
12
|
+
* columns or a legacy combined `pricingMethod` value (flat | per_unit | tiered).
|
|
13
|
+
* Keeps the engine working for rows that predate the split / haven't been
|
|
14
|
+
* migrated, and tolerates new clients that send the legacy value by mistake.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeChargePricing(charge: {
|
|
17
|
+
chargeModel?: string | null
|
|
18
|
+
pricingMethod?: string | null
|
|
19
|
+
fixedPrice?: string | null
|
|
20
|
+
pricingTableId?: string | null
|
|
21
|
+
}): { model: ChargeModel; source: PricingSource } {
|
|
22
|
+
const pm = (charge.pricingMethod ?? '').toLowerCase()
|
|
23
|
+
// Anything that historically meant "comes from a table" maps to source=table.
|
|
24
|
+
const tableLike = pm === 'table' || pm === 'per_unit' || pm === 'tiered' || pm === 'volume'
|
|
25
|
+
const source: PricingSource = tableLike
|
|
26
|
+
? 'table'
|
|
27
|
+
: pm === 'fixed'
|
|
28
|
+
? 'fixed'
|
|
29
|
+
// Legacy 'flat' could be a fixed price OR a flat table lookup (the old
|
|
30
|
+
// `calculateFlat`-via-table path), empty, or unknown: infer from the
|
|
31
|
+
// presence of a pricing table — preserving that legacy behaviour.
|
|
32
|
+
: charge.pricingTableId
|
|
33
|
+
? 'table'
|
|
34
|
+
: 'fixed'
|
|
35
|
+
|
|
36
|
+
const rawModel = (charge.chargeModel ?? '').toLowerCase()
|
|
37
|
+
const model: ChargeModel =
|
|
38
|
+
rawModel === 'flat' || rawModel === 'per_unit' || rawModel === 'volume' || rawModel === 'tiered'
|
|
39
|
+
? (rawModel as ChargeModel)
|
|
40
|
+
// No explicit model → derive from the legacy combined pricingMethod.
|
|
41
|
+
: pm === 'per_unit'
|
|
42
|
+
? 'per_unit'
|
|
43
|
+
: pm === 'tiered'
|
|
44
|
+
? 'tiered'
|
|
45
|
+
: 'flat'
|
|
46
|
+
|
|
47
|
+
return { model, source }
|
|
48
|
+
}
|
|
@@ -515,6 +515,16 @@ export class CpqProductCharge {
|
|
|
515
515
|
@Property({ name: 'charge_type', type: 'text' })
|
|
516
516
|
chargeType!: string
|
|
517
517
|
|
|
518
|
+
// XD-297: charge "shape" split into two orthogonal axes.
|
|
519
|
+
// chargeModel — how quantity drives the charge: flat | per_unit | volume | tiered
|
|
520
|
+
// pricingMethod — where the unit price comes from: fixed | table
|
|
521
|
+
// Legacy rows (pre-split) carry the old combined pricingMethod values
|
|
522
|
+
// (flat | per_unit | tiered); the migration backfills both columns and the
|
|
523
|
+
// pricing service normalises any that slip through. chargeModel is nullable
|
|
524
|
+
// so the additive migration is default-safe.
|
|
525
|
+
@Property({ name: 'charge_model', type: 'text', nullable: true })
|
|
526
|
+
chargeModel?: string | null
|
|
527
|
+
|
|
518
528
|
@Property({ name: 'pricing_method', type: 'text' })
|
|
519
529
|
pricingMethod!: string
|
|
520
530
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import { normalizeChargePricing } from './charge-pricing'
|
|
2
3
|
|
|
3
4
|
// ─── Product Specification ───────────────────────────────────────
|
|
4
5
|
|
|
@@ -140,9 +141,14 @@ export const cpqProductAttributeUpdateSchema = cpqProductAttributeCreateSchema.p
|
|
|
140
141
|
// `fixed` is a legacy alias for `flat` — older seeds (`demo_puffin`,
|
|
141
142
|
// `demo_gix`) emit it directly and ~30+ rows in prod use it. The pricing
|
|
142
143
|
// service treats them identically (fixedPrice + currencyCode, no table
|
|
143
|
-
// lookup).
|
|
144
|
-
//
|
|
145
|
-
|
|
144
|
+
// lookup). XD-297 splits the charge "shape" into two axes — chargeModel
|
|
145
|
+
// (flat | per_unit | volume | tiered) and pricingMethod (fixed | table).
|
|
146
|
+
// Input accepts either the new split OR a legacy combined pricingMethod
|
|
147
|
+
// (flat | per_unit | tiered); normalizeChargePricing() collapses both to a
|
|
148
|
+
// canonical {model, source} that the route persists.
|
|
149
|
+
const cpqChargeModelEnum = z.enum(['flat', 'per_unit', 'volume', 'tiered'])
|
|
150
|
+
// New sources (fixed | table) plus the legacy combined values for back-compat.
|
|
151
|
+
const cpqChargePricingMethodEnum = z.enum(['fixed', 'table', 'flat', 'tiered', 'per_unit'])
|
|
146
152
|
|
|
147
153
|
const requireField = (
|
|
148
154
|
ctx: z.RefinementCtx,
|
|
@@ -176,7 +182,8 @@ const cpqProductChargeBase = z.object({
|
|
|
176
182
|
name: z.string().min(1),
|
|
177
183
|
description: z.string().nullish(),
|
|
178
184
|
chargeType: z.enum(['nrc', 'mrc', 'usage']),
|
|
179
|
-
|
|
185
|
+
chargeModel: cpqChargeModelEnum.optional(),
|
|
186
|
+
pricingMethod: cpqChargePricingMethodEnum,
|
|
180
187
|
pricingTableId: z.string().uuid().nullish(),
|
|
181
188
|
priceColumnKey: z.string().nullish(),
|
|
182
189
|
fixedPrice: z.union([z.string(), z.number().transform(String)]).nullish(),
|
|
@@ -187,15 +194,17 @@ const cpqProductChargeBase = z.object({
|
|
|
187
194
|
isActive: z.boolean().optional().default(true),
|
|
188
195
|
})
|
|
189
196
|
|
|
190
|
-
// V-CHG-1:
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
197
|
+
// V-CHG-1: the (chargeModel × pricingMethod) combination must match the fields.
|
|
198
|
+
// fixed → fixedPrice + currencyCode required; table fields forbidden.
|
|
199
|
+
// flat: no quantity attr; per_unit: quantity attr required.
|
|
200
|
+
// volume / tiered + fixed is not a valid combination.
|
|
201
|
+
// table → pricingTableId + priceColumnKey required; fixedPrice forbidden.
|
|
202
|
+
// flat: no quantity attr; per_unit / volume / tiered: quantity attr required.
|
|
195
203
|
const refineChargePricingShape = (
|
|
196
204
|
ctx: z.RefinementCtx,
|
|
197
205
|
charge: {
|
|
198
|
-
|
|
206
|
+
chargeModel?: 'flat' | 'per_unit' | 'volume' | 'tiered'
|
|
207
|
+
pricingMethod?: 'fixed' | 'table' | 'flat' | 'tiered' | 'per_unit'
|
|
199
208
|
pricingTableId?: string | null
|
|
200
209
|
priceColumnKey?: string | null
|
|
201
210
|
fixedPrice?: string | null
|
|
@@ -203,21 +212,39 @@ const refineChargePricingShape = (
|
|
|
203
212
|
quantityAttributeCode?: string | null
|
|
204
213
|
},
|
|
205
214
|
) => {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
215
|
+
if (charge.pricingMethod === undefined && charge.chargeModel === undefined) return
|
|
216
|
+
const { model, source } = normalizeChargePricing(charge)
|
|
217
|
+
|
|
218
|
+
if (source === 'fixed') {
|
|
219
|
+
if (model === 'volume' || model === 'tiered') {
|
|
220
|
+
ctx.addIssue({
|
|
221
|
+
code: z.ZodIssueCode.custom,
|
|
222
|
+
path: ['pricingMethod'],
|
|
223
|
+
message: `V-CHG-1: ${model} pricing requires Table Lookup, not a fixed price`,
|
|
224
|
+
})
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
requireField(ctx, charge.fixedPrice, 'fixedPrice', 'V-CHG-1: fixedPrice is required for a fixed price')
|
|
228
|
+
requireField(ctx, charge.currencyCode, 'currencyCode', 'V-CHG-1: currencyCode is required for a fixed price')
|
|
229
|
+
forbidField(ctx, charge.pricingTableId, 'pricingTableId', 'V-CHG-1: pricingTableId must be empty for a fixed price')
|
|
230
|
+
forbidField(ctx, charge.priceColumnKey, 'priceColumnKey', 'V-CHG-1: priceColumnKey must be empty for a fixed price')
|
|
231
|
+
if (model === 'per_unit') {
|
|
232
|
+
requireField(ctx, charge.quantityAttributeCode, 'quantityAttributeCode', 'V-CHG-1: quantityAttributeCode is required for per-unit pricing')
|
|
233
|
+
} else {
|
|
234
|
+
forbidField(ctx, charge.quantityAttributeCode, 'quantityAttributeCode', 'V-CHG-1: quantityAttributeCode must be empty for a flat fixed price')
|
|
235
|
+
}
|
|
214
236
|
return
|
|
215
237
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
requireField(ctx, charge.
|
|
219
|
-
requireField(ctx, charge.
|
|
220
|
-
forbidField(ctx, charge.fixedPrice, 'fixedPrice',
|
|
238
|
+
|
|
239
|
+
// source === 'table'
|
|
240
|
+
requireField(ctx, charge.pricingTableId, 'pricingTableId', 'V-CHG-1: pricingTableId is required for table lookup')
|
|
241
|
+
requireField(ctx, charge.priceColumnKey, 'priceColumnKey', 'V-CHG-1: priceColumnKey is required for table lookup')
|
|
242
|
+
forbidField(ctx, charge.fixedPrice, 'fixedPrice', 'V-CHG-1: fixedPrice must be empty for table lookup — price comes from the table')
|
|
243
|
+
if (model === 'flat') {
|
|
244
|
+
forbidField(ctx, charge.quantityAttributeCode, 'quantityAttributeCode', 'V-CHG-1: quantityAttributeCode must be empty for a flat table price')
|
|
245
|
+
} else {
|
|
246
|
+
requireField(ctx, charge.quantityAttributeCode, 'quantityAttributeCode', `V-CHG-1: quantityAttributeCode is required for ${model} pricing`)
|
|
247
|
+
}
|
|
221
248
|
}
|
|
222
249
|
|
|
223
250
|
export const cpqProductChargeCreateSchema = cpqProductChargeBase.superRefine((data, ctx) => {
|
|
@@ -227,9 +254,12 @@ export const cpqProductChargeCreateSchema = cpqProductChargeBase.superRefine((da
|
|
|
227
254
|
export const cpqProductChargeUpdateSchema = cpqProductChargeBase.partial().extend({
|
|
228
255
|
id: z.string().uuid(),
|
|
229
256
|
}).superRefine((data, ctx) => {
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
257
|
+
// Enforce shape whenever either pricing axis is being changed — a
|
|
258
|
+
// chargeModel-only PATCH (e.g. flat → per_unit) still alters the required
|
|
259
|
+
// fields, so it must validate rather than silently persist a broken charge.
|
|
260
|
+
if (data.pricingMethod !== undefined || data.chargeModel !== undefined) {
|
|
261
|
+
refineChargePricingShape(ctx, data)
|
|
262
|
+
}
|
|
233
263
|
})
|
|
234
264
|
|
|
235
265
|
// ─── Price Rule ──────────────────────────────────────────────────
|
|
@@ -3,14 +3,22 @@ import { createModuleEvents } from '@open-mercato/shared/modules/events'
|
|
|
3
3
|
/**
|
|
4
4
|
* CPQ Module Events.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* to these to react to
|
|
6
|
+
* Subscription lifecycle events: `activated` (new-sale onboarding) plus
|
|
7
|
+
* the XD-250 ARC (Amend / Renew / Cancel / merge) events. Downstream
|
|
8
|
+
* modules (billing, notifications) subscribe to these to react to
|
|
9
|
+
* subscription mutations driven by CPQ orders.
|
|
9
10
|
*
|
|
10
11
|
* Emission rule: events fire AFTER the activation transaction commits.
|
|
11
12
|
* `cpqOrderService.activateOrder` is the sole emitter site.
|
|
12
13
|
*/
|
|
13
14
|
const events = [
|
|
15
|
+
{
|
|
16
|
+
id: 'cpq.subscription.activated',
|
|
17
|
+
label: 'Subscription Activated',
|
|
18
|
+
entity: 'subscription',
|
|
19
|
+
category: 'lifecycle',
|
|
20
|
+
clientBroadcast: true,
|
|
21
|
+
},
|
|
14
22
|
{
|
|
15
23
|
id: 'cpq.subscription.amended',
|
|
16
24
|
label: 'Subscription Amended',
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { seedCpqDemo, resetCpqDemo } from '../demo'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory mock EM mirroring the seed's find-or-create contract:
|
|
5
|
+
* `findOne` matches the catalog product by `sku` and every CPQ row by
|
|
6
|
+
* `code`; `create` mints an id; `persist` stores; `nativeDelete` is counted.
|
|
7
|
+
*/
|
|
8
|
+
function makeMockEm() {
|
|
9
|
+
const store: Array<Record<string, unknown>> = []
|
|
10
|
+
let idSeq = 0
|
|
11
|
+
const em = {
|
|
12
|
+
store,
|
|
13
|
+
findOne: jest.fn(async (_entity: unknown, where: Record<string, unknown>) => {
|
|
14
|
+
return (
|
|
15
|
+
store.find((row) => {
|
|
16
|
+
if (typeof where.sku === 'string') return row.sku === where.sku
|
|
17
|
+
if (typeof where.code === 'string') return row.code === where.code
|
|
18
|
+
return false
|
|
19
|
+
}) ?? null
|
|
20
|
+
)
|
|
21
|
+
}),
|
|
22
|
+
create: jest.fn((_entity: unknown, data: Record<string, unknown>) => ({
|
|
23
|
+
id: `id-${++idSeq}`,
|
|
24
|
+
...data,
|
|
25
|
+
})),
|
|
26
|
+
persist: jest.fn((entity: Record<string, unknown>) => {
|
|
27
|
+
store.push(entity)
|
|
28
|
+
return entity
|
|
29
|
+
}),
|
|
30
|
+
flush: jest.fn(async () => undefined),
|
|
31
|
+
nativeDelete: jest.fn(async () => 0),
|
|
32
|
+
}
|
|
33
|
+
return em as never as Parameters<typeof seedCpqDemo>[0] & {
|
|
34
|
+
store: Array<Record<string, unknown>>
|
|
35
|
+
nativeDelete: jest.Mock
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SCOPE = { tenantId: 'tnt-1', organizationId: 'org-1' }
|
|
40
|
+
|
|
41
|
+
describe('seedCpqDemo', () => {
|
|
42
|
+
it('creates the product, spec, offering and charge on a fresh tenant', async () => {
|
|
43
|
+
const em = makeMockEm()
|
|
44
|
+
const res = await seedCpqDemo(em, SCOPE)
|
|
45
|
+
expect(res).toEqual({
|
|
46
|
+
productsCreated: 1,
|
|
47
|
+
specsCreated: 1,
|
|
48
|
+
offeringsCreated: 1,
|
|
49
|
+
chargesCreated: 1,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('is idempotent — a second run creates nothing', async () => {
|
|
54
|
+
const em = makeMockEm()
|
|
55
|
+
await seedCpqDemo(em, SCOPE)
|
|
56
|
+
const second = await seedCpqDemo(em, SCOPE)
|
|
57
|
+
expect(second).toEqual({
|
|
58
|
+
productsCreated: 0,
|
|
59
|
+
specsCreated: 0,
|
|
60
|
+
offeringsCreated: 0,
|
|
61
|
+
chargesCreated: 0,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('links the charge to the seeded offering (per-offering uniqueness)', async () => {
|
|
66
|
+
const em = makeMockEm()
|
|
67
|
+
await seedCpqDemo(em, SCOPE)
|
|
68
|
+
const offering = em.store.find((r) => r.code === 'DEMO-CPQ-CLOUD-OFFER') as { id: string }
|
|
69
|
+
const charge = em.store.find((r) => r.code === 'DEMO-CPQ-CLOUD-MRC') as { offeringId: string }
|
|
70
|
+
expect(charge?.offeringId).toBe(offering?.id)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('reset issues a delete for charge, offering, spec and product', async () => {
|
|
74
|
+
const em = makeMockEm()
|
|
75
|
+
await resetCpqDemo(em, SCOPE)
|
|
76
|
+
expect(em.nativeDelete).toHaveBeenCalledTimes(4)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal, self-contained CPQ test-data seed.
|
|
5
|
+
*
|
|
6
|
+
* Creates one neutral catalog product with a Specification, an Offering, and
|
|
7
|
+
* a fixed monthly charge — enough to exercise the CPQ admin (Product
|
|
8
|
+
* Specifications / Offerings lists) and quoting locally. Idempotent: every
|
|
9
|
+
* row is matched by a stable `DEMO-CPQ-*` code / sku, so re-running is a
|
|
10
|
+
* no-op rather than a duplicate.
|
|
11
|
+
*
|
|
12
|
+
* Intentionally generic (NOT customer-specific) so it ships with
|
|
13
|
+
* `@dainamite/cpq`. Rich, vertical demo catalogs live in the `demo_<x>`
|
|
14
|
+
* app modules + `mercato demo_tenants seed`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type CpqDemoScope = { tenantId: string; organizationId: string }
|
|
18
|
+
export type CpqDemoResult = {
|
|
19
|
+
productsCreated: number
|
|
20
|
+
specsCreated: number
|
|
21
|
+
offeringsCreated: number
|
|
22
|
+
chargesCreated: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PRODUCT_SKU = 'DEMO-CPQ-CLOUD'
|
|
26
|
+
const SPEC_CODE = 'DEMO-CPQ-CLOUD-SPEC'
|
|
27
|
+
const OFFERING_CODE = 'DEMO-CPQ-CLOUD-OFFER'
|
|
28
|
+
const CHARGE_CODE = 'DEMO-CPQ-CLOUD-MRC'
|
|
29
|
+
|
|
30
|
+
export async function resetCpqDemo(em: EntityManager, scope: CpqDemoScope): Promise<void> {
|
|
31
|
+
const { CpqProductSpecification, CpqProductOffering, CpqProductCharge } = await import(
|
|
32
|
+
'../../data/entities'
|
|
33
|
+
)
|
|
34
|
+
const { CatalogProduct } = await import('@open-mercato/core/modules/catalog/data/entities')
|
|
35
|
+
// Leaves first, roots last.
|
|
36
|
+
await em.nativeDelete(CpqProductCharge, { ...scope, code: CHARGE_CODE } as never)
|
|
37
|
+
await em.nativeDelete(CpqProductOffering, { ...scope, code: OFFERING_CODE } as never)
|
|
38
|
+
await em.nativeDelete(CpqProductSpecification, { ...scope, code: SPEC_CODE } as never)
|
|
39
|
+
await em.nativeDelete(CatalogProduct, { ...scope, sku: PRODUCT_SKU } as never)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function seedCpqDemo(
|
|
43
|
+
em: EntityManager,
|
|
44
|
+
scope: CpqDemoScope,
|
|
45
|
+
options: { reset?: boolean } = {},
|
|
46
|
+
): Promise<CpqDemoResult> {
|
|
47
|
+
if (options.reset) await resetCpqDemo(em, scope)
|
|
48
|
+
|
|
49
|
+
const { CpqProductSpecification, CpqProductOffering, CpqProductCharge } = await import(
|
|
50
|
+
'../../data/entities'
|
|
51
|
+
)
|
|
52
|
+
const { CatalogProduct } = await import('@open-mercato/core/modules/catalog/data/entities')
|
|
53
|
+
|
|
54
|
+
const result: CpqDemoResult = {
|
|
55
|
+
productsCreated: 0,
|
|
56
|
+
specsCreated: 0,
|
|
57
|
+
offeringsCreated: 0,
|
|
58
|
+
chargesCreated: 0,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Catalog product (idempotent by sku) ──
|
|
62
|
+
let product = await em.findOne(CatalogProduct, { ...scope, sku: PRODUCT_SKU, deletedAt: null } as never)
|
|
63
|
+
if (!product) {
|
|
64
|
+
product = em.create(CatalogProduct, {
|
|
65
|
+
...scope,
|
|
66
|
+
title: 'Demo Cloud Plan',
|
|
67
|
+
sku: PRODUCT_SKU,
|
|
68
|
+
handle: PRODUCT_SKU.toLowerCase(),
|
|
69
|
+
productType: 'configurable',
|
|
70
|
+
description: 'Generic CPQ demo product seeded by `mercato cpq seed`.',
|
|
71
|
+
isConfigurable: true,
|
|
72
|
+
isActive: true,
|
|
73
|
+
})
|
|
74
|
+
em.persist(product)
|
|
75
|
+
await em.flush()
|
|
76
|
+
result.productsCreated += 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Specification (idempotent by code) ──
|
|
80
|
+
let spec = await em.findOne(CpqProductSpecification, { ...scope, code: SPEC_CODE, deletedAt: null } as never)
|
|
81
|
+
if (!spec) {
|
|
82
|
+
spec = em.create(CpqProductSpecification, {
|
|
83
|
+
...scope,
|
|
84
|
+
productId: product.id,
|
|
85
|
+
code: SPEC_CODE,
|
|
86
|
+
name: 'Demo Cloud Specification',
|
|
87
|
+
specType: 'simple',
|
|
88
|
+
lifecycleStatus: 'active',
|
|
89
|
+
isActive: true,
|
|
90
|
+
})
|
|
91
|
+
em.persist(spec)
|
|
92
|
+
await em.flush()
|
|
93
|
+
result.specsCreated += 1
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Offering (idempotent by code) ──
|
|
97
|
+
let offering = await em.findOne(CpqProductOffering, { ...scope, code: OFFERING_CODE, deletedAt: null } as never)
|
|
98
|
+
if (!offering) {
|
|
99
|
+
offering = em.create(CpqProductOffering, {
|
|
100
|
+
...scope,
|
|
101
|
+
specId: spec.id,
|
|
102
|
+
code: OFFERING_CODE,
|
|
103
|
+
name: 'Demo Cloud Offering',
|
|
104
|
+
offeringType: 'simple',
|
|
105
|
+
designTimeValues: {},
|
|
106
|
+
lifecycleStatus: 'active',
|
|
107
|
+
isActive: true,
|
|
108
|
+
})
|
|
109
|
+
em.persist(offering)
|
|
110
|
+
await em.flush()
|
|
111
|
+
result.offeringsCreated += 1
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Charge (idempotent by offering + code, matching the DB unique
|
|
115
|
+
// constraint `(org, tenant, offering_id, code)`) — fixed monthly fee ──
|
|
116
|
+
const charge = await em.findOne(CpqProductCharge, {
|
|
117
|
+
...scope,
|
|
118
|
+
offeringId: offering.id,
|
|
119
|
+
code: CHARGE_CODE,
|
|
120
|
+
deletedAt: null,
|
|
121
|
+
} as never)
|
|
122
|
+
if (!charge) {
|
|
123
|
+
em.persist(
|
|
124
|
+
em.create(CpqProductCharge, {
|
|
125
|
+
...scope,
|
|
126
|
+
productId: product.id,
|
|
127
|
+
offeringId: offering.id,
|
|
128
|
+
code: CHARGE_CODE,
|
|
129
|
+
name: 'Monthly platform fee',
|
|
130
|
+
chargeType: 'mrc',
|
|
131
|
+
chargeModel: 'flat',
|
|
132
|
+
pricingMethod: 'fixed',
|
|
133
|
+
fixedPrice: '99.0000',
|
|
134
|
+
// Neutral default — this seed ships generically with @dainamite/cpq.
|
|
135
|
+
currencyCode: 'USD',
|
|
136
|
+
sortOrder: 0,
|
|
137
|
+
isActive: true,
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
result.chargesCreated += 1
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await em.flush()
|
|
144
|
+
return result
|
|
145
|
+
}
|
|
@@ -4320,6 +4320,22 @@
|
|
|
4320
4320
|
"enumItems": [],
|
|
4321
4321
|
"mappedType": "text"
|
|
4322
4322
|
},
|
|
4323
|
+
"charge_model": {
|
|
4324
|
+
"name": "charge_model",
|
|
4325
|
+
"type": "text",
|
|
4326
|
+
"unsigned": false,
|
|
4327
|
+
"autoincrement": false,
|
|
4328
|
+
"primary": false,
|
|
4329
|
+
"nullable": true,
|
|
4330
|
+
"unique": false,
|
|
4331
|
+
"length": null,
|
|
4332
|
+
"precision": null,
|
|
4333
|
+
"scale": null,
|
|
4334
|
+
"default": null,
|
|
4335
|
+
"comment": null,
|
|
4336
|
+
"enumItems": [],
|
|
4337
|
+
"mappedType": "text"
|
|
4338
|
+
},
|
|
4323
4339
|
"pricing_method": {
|
|
4324
4340
|
"name": "pricing_method",
|
|
4325
4341
|
"type": "text",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* XD-297 — split the charge "shape" into two orthogonal axes.
|
|
5
|
+
*
|
|
6
|
+
* Before: `cpq_product_charges.pricing_method` conflated *how quantity drives
|
|
7
|
+
* the charge* (flat | per_unit | tiered) with *where the price comes from*
|
|
8
|
+
* (a fixed price for `flat`, a table lookup for `per_unit` / `tiered`).
|
|
9
|
+
*
|
|
10
|
+
* After:
|
|
11
|
+
* - `charge_model` — flat | per_unit | volume | tiered (new column)
|
|
12
|
+
* - `pricing_method` — fixed | table (repurposed)
|
|
13
|
+
*
|
|
14
|
+
* The backfill maps every legacy row to the equivalent split shape:
|
|
15
|
+
* flat → (charge_model=flat, pricing_method=fixed)
|
|
16
|
+
* per_unit → (charge_model=per_unit, pricing_method=table)
|
|
17
|
+
* tiered → (charge_model=tiered, pricing_method=table)
|
|
18
|
+
*
|
|
19
|
+
* Hand-written because the data backfill (re-coding existing `pricing_method`
|
|
20
|
+
* values) is beyond what MikroORM's schema generator can emit. The pricing
|
|
21
|
+
* service additionally normalises any row that predates this migration, so the
|
|
22
|
+
* change is safe even before it is applied.
|
|
23
|
+
*/
|
|
24
|
+
export class Migration20260602000000_cpq_charge_model_split extends Migration {
|
|
25
|
+
async up(): Promise<void> {
|
|
26
|
+
this.addSql(`alter table "cpq_product_charges" add column "charge_model" text null;`)
|
|
27
|
+
|
|
28
|
+
// Derive charge_model from the legacy combined pricing_method FIRST …
|
|
29
|
+
this.addSql(`update "cpq_product_charges" set "charge_model" = case
|
|
30
|
+
when "pricing_method" = 'per_unit' then 'per_unit'
|
|
31
|
+
when "pricing_method" = 'tiered' then 'tiered'
|
|
32
|
+
else 'flat'
|
|
33
|
+
end where "charge_model" is null;`)
|
|
34
|
+
|
|
35
|
+
// … then collapse pricing_method to the new {fixed, table} axis.
|
|
36
|
+
this.addSql(`update "cpq_product_charges" set "pricing_method" = case
|
|
37
|
+
when "pricing_method" in ('per_unit', 'tiered', 'volume', 'table') then 'table'
|
|
38
|
+
else 'fixed'
|
|
39
|
+
end;`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async down(): Promise<void> {
|
|
43
|
+
// Best-effort re-collapse to the legacy combined value. `per_unit + fixed`
|
|
44
|
+
// reverts to `flat` (the old fixed-price path: 1 × fixedPrice) rather than
|
|
45
|
+
// `per_unit` (old table lookup) so it doesn't silently under-bill; only a
|
|
46
|
+
// genuine `per_unit + table` keeps the per_unit model. `volume` has no
|
|
47
|
+
// legacy equivalent and falls back to per_unit.
|
|
48
|
+
this.addSql(`update "cpq_product_charges" set "pricing_method" = case
|
|
49
|
+
when "charge_model" = 'tiered' then 'tiered'
|
|
50
|
+
when "charge_model" = 'per_unit' and "pricing_method" = 'table' then 'per_unit'
|
|
51
|
+
when "charge_model" = 'volume' then 'per_unit'
|
|
52
|
+
else 'flat'
|
|
53
|
+
end;`)
|
|
54
|
+
|
|
55
|
+
this.addSql(`alter table "cpq_product_charges" drop column "charge_model";`)
|
|
56
|
+
}
|
|
57
|
+
}
|