@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
@@ -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 flat with stray pricingTableId', () => {
294
- const result = cpqProductChargeCreateSchema.safeParse({ ...flatBase, pricingTableId: VALID_UUID })
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('normalises legacy `fixed` to `flat` on input', () => {
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). We accept it on input and normalise to `flat` so storage
144
- // stays canonical.
145
- const cpqChargePricingMethodEnum = z.enum(['flat', 'fixed', 'tiered', 'per_unit'])
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
- pricingMethod: cpqChargePricingMethodEnum.transform((v) => (v === 'fixed' ? 'flat' : v)),
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: pricing method must match the configured fields.
191
- // - flat → fixedPrice + currencyCode required; table fields forbidden
192
- // - per_unit → pricingTableId + priceColumnKey + quantityAttributeCode
193
- // required; fixedPrice forbidden
194
- // - tiered → same as per_unit (rangeFrom/rangeTo come from the table)
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
- pricingMethod?: 'flat' | 'tiered' | 'per_unit'
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
- const method = charge.pricingMethod
207
- if (!method) return
208
- if (method === 'flat') {
209
- requireField(ctx, charge.fixedPrice, 'fixedPrice', 'V-CHG-1: fixedPrice is required for flat pricing')
210
- requireField(ctx, charge.currencyCode, 'currencyCode', 'V-CHG-1: currencyCode is required for flat pricing')
211
- forbidField(ctx, charge.pricingTableId, 'pricingTableId', 'V-CHG-1: pricingTableId must be empty for flat pricing')
212
- forbidField(ctx, charge.priceColumnKey, 'priceColumnKey', 'V-CHG-1: priceColumnKey must be empty for flat pricing')
213
- forbidField(ctx, charge.quantityAttributeCode, 'quantityAttributeCode', 'V-CHG-1: quantityAttributeCode must be empty for flat pricing')
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
- // per_unit | tiered
217
- requireField(ctx, charge.pricingTableId, 'pricingTableId', `V-CHG-1: pricingTableId is required for ${method} pricing`)
218
- requireField(ctx, charge.priceColumnKey, 'priceColumnKey', `V-CHG-1: priceColumnKey is required for ${method} pricing`)
219
- requireField(ctx, charge.quantityAttributeCode, 'quantityAttributeCode', `V-CHG-1: quantityAttributeCode is required for ${method} pricing`)
220
- forbidField(ctx, charge.fixedPrice, 'fixedPrice', `V-CHG-1: fixedPrice must be empty for ${method} pricing — price comes from the pricing table`)
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
- // On update, only enforce shape if pricingMethod is present in payload
231
- // (PATCH semantics — service merges with the persisted row).
232
- if (data.pricingMethod !== undefined) refineChargePricingShape(ctx, data)
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
- * Currently only the XD-250 ARC (Amend / Renew / Cancel) lifecycle events
7
- * are declared here. Downstream modules (billing, notifications) subscribe
8
- * to these to react to subscription mutations driven by CPQ orders.
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
+ }