@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
@@ -5,7 +5,9 @@ import { useParams, useRouter } from 'next/navigation'
5
5
  import { Alert } from '@open-mercato/ui/primitives/alert'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Tag } from '@open-mercato/ui/primitives/tag'
8
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
8
9
  import { NumberInput } from '../../../../components/NumberInput'
10
+ import { normalizeChargePricing } from '../../../../data/charge-pricing'
9
11
  import {
10
12
  chargeTypeMap,
11
13
  formatStatusLabel,
@@ -63,6 +65,7 @@ type Charge = {
63
65
  name: string
64
66
  description: string | null
65
67
  chargeType: string
68
+ chargeModel: string | null
66
69
  pricingMethod: string
67
70
  pricingTableId: string | null
68
71
  priceColumnKey: string | null
@@ -98,6 +101,22 @@ type Specification = {
98
101
 
99
102
  type PricingTableRef = { id: string; code: string; name: string; dimensions: Array<{ key: string; label: string }>; priceColumns: Array<{ key: string; label: string }> }
100
103
 
104
+ // Shared by the eager mount-load and the lazy editor dropdown. Returns [] on
105
+ // failure so a transient error degrades gracefully instead of blanking the page.
106
+ async function fetchPricingTables(): Promise<PricingTableRef[]> {
107
+ try {
108
+ const { ok, result } = await apiCall<{ items?: PricingTableRef[] }>('/api/cpq/pricing-tables?pageSize=100')
109
+ if (!ok) {
110
+ console.warn('[cpq] Failed to load pricing tables for the charges list')
111
+ return []
112
+ }
113
+ return result?.items ?? []
114
+ } catch (err) {
115
+ console.warn('[cpq] Failed to load pricing tables for the charges list:', err)
116
+ return []
117
+ }
118
+ }
119
+
101
120
  type EditingCharge = Omit<Charge, 'id'> & { id?: string }
102
121
 
103
122
  // ─── Defaults ───────────────────────────────────────────────────
@@ -119,7 +138,8 @@ const EMPTY_CHARGE: EditingCharge = {
119
138
  name: '',
120
139
  description: null,
121
140
  chargeType: 'mrc',
122
- pricingMethod: 'flat',
141
+ chargeModel: 'flat',
142
+ pricingMethod: 'fixed',
123
143
  pricingTableId: null,
124
144
  priceColumnKey: null,
125
145
  fixedPrice: null,
@@ -131,17 +151,24 @@ const EMPTY_CHARGE: EditingCharge = {
131
151
  }
132
152
 
133
153
 
134
- const PRICING_METHOD_LABELS: Record<string, string> = {
135
- flat: 'flat price',
136
- fixed: 'flat price', // legacy alias — see validators.ts
137
- per_unit: 'per unit',
138
- tiered: 'tiered',
154
+ // XD-297: the charge "shape" is two axes — Charge Model (how quantity drives
155
+ // the charge) and Pricing Method (where the unit price comes from).
156
+ const CHARGE_MODEL_LABELS: Record<string, string> = {
157
+ flat: 'Flat Fee',
158
+ per_unit: 'Per Unit',
159
+ volume: 'Volume-based',
160
+ tiered: 'Tiered',
139
161
  }
140
162
 
141
- // V-CHG-1 in `validators.ts`: per_unit/tiered need table + column + qty
142
- // attribute; flat needs fixedPrice + currencyCode. Mirror it in the UI so
143
- // users see what's missing without round-tripping to the server.
163
+ const PRICING_SOURCE_LABELS: Record<string, string> = {
164
+ fixed: 'Fixed Price',
165
+ table: 'Table Lookup',
166
+ }
167
+
168
+ // V-CHG-1 in `validators.ts`: mirror the (chargeModel × pricingMethod) rules in
169
+ // the UI so users see what's missing without round-tripping to the server.
144
170
  type ChargeShape = {
171
+ chargeModel: string | null
145
172
  pricingMethod: string
146
173
  pricingTableId: string | null
147
174
  priceColumnKey: string | null
@@ -155,15 +182,16 @@ function isChargeComplete(charge: ChargeShape): boolean {
155
182
  }
156
183
 
157
184
  function chargeMissingFields(charge: ChargeShape): string[] {
158
- const method = charge.pricingMethod === 'fixed' ? 'flat' : charge.pricingMethod
185
+ const { model, source } = normalizeChargePricing(charge)
159
186
  const missing: string[] = []
160
- if (method === 'flat') {
187
+ if (source === 'fixed') {
161
188
  if (!charge.fixedPrice) missing.push('Fixed Price')
162
189
  if (!charge.currencyCode) missing.push('Currency')
163
- } else if (method === 'per_unit' || method === 'tiered') {
190
+ if (model === 'per_unit' && !charge.quantityAttributeCode) missing.push('Quantity Attribute')
191
+ } else {
164
192
  if (!charge.pricingTableId) missing.push('Pricing Table')
165
193
  if (!charge.priceColumnKey) missing.push('Price Column')
166
- if (!charge.quantityAttributeCode) missing.push('Quantity Attribute')
194
+ if (model !== 'flat' && !charge.quantityAttributeCode) missing.push('Quantity Attribute')
167
195
  }
168
196
  return missing
169
197
  }
@@ -245,6 +273,14 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
245
273
  setSpecDetail(data.specification ?? null)
246
274
  setComponents(data.components ?? [])
247
275
 
276
+ // Charges reference pricing tables by id; load the tables up front so
277
+ // the list renders their names instead of "missing". The charge editor
278
+ // also lazy-loads them, but the read view needs them on mount/refresh.
279
+ if ((data.charges ?? []).some((c) => c.pricingTableId)) {
280
+ const tables = await fetchPricingTables()
281
+ if (!cancelled) setPricingTables(tables)
282
+ }
283
+
248
284
  // Load attributes for the spec
249
285
  if (data.specId) {
250
286
  const attrRes = await fetch(`/api/cpq/product-attributes?specId=${encodeURIComponent(data.specId)}&pageSize=200`)
@@ -362,16 +398,18 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
362
398
 
363
399
  const loadPricingTables = async () => {
364
400
  if (pricingTables.length > 0) return
365
- const res = await fetch('/api/cpq/pricing-tables?pageSize=200')
366
- if (res.ok) {
367
- const data = await res.json()
368
- setPricingTables(data.items ?? [])
369
- }
401
+ setPricingTables(await fetchPricingTables())
370
402
  }
371
403
 
372
404
  const startEditCharge = (charge?: Charge) => {
373
- const editing = charge ? { ...charge } : { ...EMPTY_CHARGE }
374
- setEditingCharge(editing)
405
+ if (charge) {
406
+ // Normalise legacy rows (chargeModel null, combined pricingMethod) into
407
+ // the split shape the form's two dropdowns expect.
408
+ const { model, source } = normalizeChargePricing(charge)
409
+ setEditingCharge({ ...charge, chargeModel: model, pricingMethod: source })
410
+ } else {
411
+ setEditingCharge({ ...EMPTY_CHARGE })
412
+ }
375
413
  loadPricingTables()
376
414
  }
377
415
 
@@ -759,22 +797,55 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
759
797
  <option value="usage">Usage</option>
760
798
  </select>
761
799
  </div>
800
+ <div>
801
+ <label className="block text-xs font-medium mb-1">Charge Model</label>
802
+ <select
803
+ value={editingCharge.chargeModel ?? 'flat'}
804
+ onChange={(e) => {
805
+ const model = e.target.value
806
+ const next = { ...editingCharge, chargeModel: model }
807
+ // Volume / tiered are graduated over table ranges — they can't use a fixed price.
808
+ if ((model === 'volume' || model === 'tiered') && editingCharge.pricingMethod !== 'table') {
809
+ next.pricingMethod = 'table'
810
+ next.fixedPrice = null
811
+ }
812
+ // Flat never takes a quantity attribute.
813
+ if (model === 'flat') next.quantityAttributeCode = null
814
+ setEditingCharge(next)
815
+ }}
816
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
817
+ >
818
+ <option value="flat">Flat Fee</option>
819
+ <option value="per_unit">Per Unit</option>
820
+ <option value="volume">Volume-based</option>
821
+ <option value="tiered">Tiered</option>
822
+ </select>
823
+ </div>
762
824
  <div>
763
825
  <label className="block text-xs font-medium mb-1">Pricing Method</label>
764
- <select value={editingCharge.pricingMethod} onChange={(e) => {
765
- const method = e.target.value
766
- if (method === 'flat') {
767
- setEditingCharge({ ...editingCharge, pricingMethod: method, pricingTableId: null, priceColumnKey: null, quantityAttributeCode: null })
768
- } else {
769
- setEditingCharge({ ...editingCharge, pricingMethod: method, fixedPrice: null })
770
- }
771
- }} className="w-full rounded-md border px-2 py-1.5 text-sm">
772
- <option value="flat">Flat Price</option>
773
- <option value="per_unit">Per Unit (table lookup)</option>
774
- <option value="tiered">Tiered (table lookup)</option>
826
+ <select
827
+ value={editingCharge.pricingMethod}
828
+ onChange={(e) => {
829
+ const source = e.target.value
830
+ if (source === 'fixed') {
831
+ // Only per_unit keeps the quantity attribute under a fixed
832
+ // price; flat must drop it (V-CHG-1 forbids it).
833
+ const keepQty = editingCharge.chargeModel === 'per_unit'
834
+ setEditingCharge({ ...editingCharge, pricingMethod: source, pricingTableId: null, priceColumnKey: null, ...(keepQty ? {} : { quantityAttributeCode: null }) })
835
+ } else {
836
+ setEditingCharge({ ...editingCharge, pricingMethod: source, fixedPrice: null })
837
+ }
838
+ }}
839
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
840
+ >
841
+ {/* Volume / tiered are graduated over table ranges — a fixed price isn't applicable. */}
842
+ {editingCharge.chargeModel !== 'volume' && editingCharge.chargeModel !== 'tiered' && (
843
+ <option value="fixed">Fixed Price</option>
844
+ )}
845
+ <option value="table">Table Lookup</option>
775
846
  </select>
776
847
  </div>
777
- {(editingCharge.pricingMethod === 'flat' || editingCharge.pricingMethod === 'fixed') ? (
848
+ {editingCharge.pricingMethod === 'fixed' ? (
778
849
  <>
779
850
  <div>
780
851
  <label className="block text-xs font-medium mb-1">Fixed Price <span className="text-destructive">*</span></label>
@@ -799,7 +870,7 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
799
870
  <p className="text-xs text-warning mt-0.5">
800
871
  No pricing tables yet —{' '}
801
872
  <a href="/backend/cpq/pricing-tables" className="text-primary hover:underline">create one</a>{' '}
802
- before using {editingCharge.pricingMethod} pricing.
873
+ before using Table Lookup pricing.
803
874
  </p>
804
875
  )}
805
876
  </div>
@@ -812,27 +883,32 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
812
883
  ))}
813
884
  </select>
814
885
  </div>
815
- <div>
816
- <label className="block text-xs font-medium mb-1">Quantity Attribute <span className="text-destructive">*</span></label>
817
- <select value={editingCharge.quantityAttributeCode ?? ''} onChange={(e) => setEditingCharge({ ...editingCharge, quantityAttributeCode: e.target.value || null })} className="w-full rounded-md border px-2 py-1.5 text-sm">
818
- <option value="">Select attribute...</option>
819
- {attributes.filter((a) => a.attributeType === 'number').map((a) => (
820
- <option key={a.code} value={a.code}>{a.name} ({a.code})</option>
821
- ))}
822
- </select>
823
- <p className="text-xs text-muted-foreground mt-0.5">Attribute whose value drives the quantity for pricing</p>
824
- {attributes.filter((a) => a.attributeType === 'number').length === 0 && form.specId && (
825
- <p className="text-xs text-warning mt-0.5">
826
- Spec has no number attributes —{' '}
827
- <a href={`/backend/cpq/specifications/${form.specId}`} className="text-primary hover:underline">
828
- add one to the spec
829
- </a>{' '}
830
- first.
831
- </p>
832
- )}
833
- </div>
834
886
  </>
835
887
  )}
888
+ {/* Quantity attribute drives the unit count — needed for per-unit
889
+ (any source) and for volume / tiered table lookups. */}
890
+ {((editingCharge.pricingMethod === 'table' && editingCharge.chargeModel !== 'flat') ||
891
+ (editingCharge.pricingMethod === 'fixed' && editingCharge.chargeModel === 'per_unit')) && (
892
+ <div>
893
+ <label className="block text-xs font-medium mb-1">Quantity Attribute <span className="text-destructive">*</span></label>
894
+ <select value={editingCharge.quantityAttributeCode ?? ''} onChange={(e) => setEditingCharge({ ...editingCharge, quantityAttributeCode: e.target.value || null })} className="w-full rounded-md border px-2 py-1.5 text-sm">
895
+ <option value="">Select attribute...</option>
896
+ {attributes.filter((a) => a.attributeType === 'number').map((a) => (
897
+ <option key={a.code} value={a.code}>{a.name} ({a.code})</option>
898
+ ))}
899
+ </select>
900
+ <p className="text-xs text-muted-foreground mt-0.5">Attribute whose value drives the quantity for pricing</p>
901
+ {attributes.filter((a) => a.attributeType === 'number').length === 0 && form.specId && (
902
+ <p className="text-xs text-warning mt-0.5">
903
+ Spec has no number attributes —{' '}
904
+ <a href={`/backend/cpq/specifications/${form.specId}`} className="text-primary hover:underline">
905
+ add one to the spec
906
+ </a>{' '}
907
+ first.
908
+ </p>
909
+ )}
910
+ </div>
911
+ )}
836
912
  <div>
837
913
  <label className="block text-xs font-medium mb-1">Sort Order</label>
838
914
  <NumberInput
@@ -902,7 +978,7 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
902
978
  const qtyAttr = charge.quantityAttributeCode
903
979
  ? attributes.find((a) => a.code === charge.quantityAttributeCode)
904
980
  : null
905
- const isFlat = charge.pricingMethod === 'flat' || charge.pricingMethod === 'fixed'
981
+ const { model, source } = normalizeChargePricing(charge)
906
982
  return (
907
983
  <tr key={charge.id} className="border-b">
908
984
  <td className="px-4 py-3 font-mono text-xs">{charge.code}</td>
@@ -917,22 +993,35 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
917
993
  {charge.chargeType.toUpperCase()}
918
994
  </Tag>
919
995
  </td>
920
- <td className="px-4 py-3 text-xs">{PRICING_METHOD_LABELS[charge.pricingMethod] ?? charge.pricingMethod}</td>
921
996
  <td className="px-4 py-3 text-xs">
922
- {isFlat ? (
923
- charge.fixedPrice != null
924
- ? `${charge.currencyCode ?? 'USD'} ${charge.fixedPrice}`
925
- : <span className="text-muted-foreground">—</span>
997
+ {CHARGE_MODEL_LABELS[model] ?? model}
998
+ <span className="text-muted-foreground"> · {PRICING_SOURCE_LABELS[source] ?? source}</span>
999
+ </td>
1000
+ <td className="px-4 py-3 text-xs">
1001
+ {source === 'fixed' ? (
1002
+ <div className="flex flex-col gap-0.5">
1003
+ {charge.fixedPrice != null
1004
+ ? <span>{`${charge.currencyCode ?? 'USD'} ${charge.fixedPrice}`}</span>
1005
+ : <span className="text-muted-foreground">—</span>}
1006
+ {model === 'per_unit' && (
1007
+ <span>
1008
+ <span className="text-muted-foreground">× Qty:</span>{' '}
1009
+ {qtyAttr ? `${qtyAttr.name} (${qtyAttr.code})` : <span className="text-destructive">missing</span>}
1010
+ </span>
1011
+ )}
1012
+ </div>
926
1013
  ) : (
927
1014
  <div className="flex flex-col gap-0.5">
928
1015
  <span>
929
1016
  <span className="text-muted-foreground">Table:</span>{' '}
930
1017
  {tableRef ? `${tableRef.name} → ${charge.priceColumnKey ?? '?'}` : <span className="text-destructive">missing</span>}
931
1018
  </span>
932
- <span>
933
- <span className="text-muted-foreground">× Qty:</span>{' '}
934
- {qtyAttr ? `${qtyAttr.name} (${qtyAttr.code})` : <span className="text-destructive">missing</span>}
935
- </span>
1019
+ {model !== 'flat' && (
1020
+ <span>
1021
+ <span className="text-muted-foreground">× Qty:</span>{' '}
1022
+ {qtyAttr ? `${qtyAttr.name} (${qtyAttr.code})` : <span className="text-destructive">missing</span>}
1023
+ </span>
1024
+ )}
936
1025
  </div>
937
1026
  )}
938
1027
  </td>
@@ -247,6 +247,13 @@ export function EntriesEditor({
247
247
  <table className="w-full text-sm">
248
248
  <thead>
249
249
  <tr className="border-b bg-muted/50">
250
+ {table.dimensions.map((dim) => (
251
+ <th key={dim.key} className="px-3 py-3 text-left font-medium whitespace-nowrap">
252
+ {dim.label}
253
+ </th>
254
+ ))}
255
+ {/* Tier / range columns sit between the dimension lookups and the
256
+ prices they qualify, so the row reads "what → how much". */}
250
257
  {hasTieredEntries && (
251
258
  <>
252
259
  <th className="px-3 py-3 text-left font-medium whitespace-nowrap">Tier #</th>
@@ -254,11 +261,6 @@ export function EntriesEditor({
254
261
  <th className="px-3 py-3 text-left font-medium whitespace-nowrap">Range To</th>
255
262
  </>
256
263
  )}
257
- {table.dimensions.map((dim) => (
258
- <th key={dim.key} className="px-3 py-3 text-left font-medium whitespace-nowrap">
259
- {dim.label}
260
- </th>
261
- ))}
262
264
  {table.priceColumns.map((col) => (
263
265
  <th key={col.key} className="px-3 py-3 text-right font-medium whitespace-nowrap">
264
266
  {col.label}
@@ -314,6 +316,23 @@ function EntryRow({
314
316
 
315
317
  return (
316
318
  <tr className={`border-b hover:bg-muted/30 ${rowBg}`}>
319
+ {table.dimensions.map((dim) => (
320
+ <td key={dim.key} className="px-3 py-2">
321
+ {readOnly ? (
322
+ entry.dimensionValues?.[dim.key] ?? '—'
323
+ ) : (
324
+ <Input
325
+ value={entry.dimensionValues?.[dim.key] ?? ''}
326
+ onChange={(e) =>
327
+ onChange({
328
+ dimensionValues: { ...entry.dimensionValues, [dim.key]: e.target.value },
329
+ })
330
+ }
331
+ className="w-28 h-8"
332
+ />
333
+ )}
334
+ </td>
335
+ ))}
317
336
  {hasTieredEntries && (
318
337
  <>
319
338
  <td className="px-3 py-2">
@@ -355,23 +374,6 @@ function EntryRow({
355
374
  </td>
356
375
  </>
357
376
  )}
358
- {table.dimensions.map((dim) => (
359
- <td key={dim.key} className="px-3 py-2">
360
- {readOnly ? (
361
- entry.dimensionValues?.[dim.key] ?? '—'
362
- ) : (
363
- <Input
364
- value={entry.dimensionValues?.[dim.key] ?? ''}
365
- onChange={(e) =>
366
- onChange({
367
- dimensionValues: { ...entry.dimensionValues, [dim.key]: e.target.value },
368
- })
369
- }
370
- className="w-28 h-8"
371
- />
372
- )}
373
- </td>
374
- ))}
375
377
  {table.priceColumns.map((col) => (
376
378
  <td key={col.key} className="px-3 py-2 text-right font-mono">
377
379
  {readOnly ? (
@@ -999,7 +999,10 @@ export default function SpecificationDetailPage(props: { params?: { id?: string
999
999
  </div>
1000
1000
  <div>
1001
1001
  <label className="block text-xs font-medium mb-1">Type</label>
1002
- <select value={editingAttr.attributeType} onChange={(e) => setEditingAttr({ ...editingAttr, attributeType: e.target.value })} className="w-full rounded-md border px-2 py-1.5 text-sm">
1002
+ {/* Reset the default when the type changes — a default kept from a
1003
+ different type (e.g. a numeric default on a now-boolean attr)
1004
+ would be invalid and confusing. */}
1005
+ <select value={editingAttr.attributeType} onChange={(e) => setEditingAttr({ ...editingAttr, attributeType: e.target.value, defaultValue: null })} className="w-full rounded-md border px-2 py-1.5 text-sm">
1003
1006
  <option value="text">Text</option>
1004
1007
  <option value="number">Number</option>
1005
1008
  <option value="boolean">Boolean</option>
@@ -1027,6 +1030,75 @@ export default function SpecificationDetailPage(props: { params?: { id?: string
1027
1030
  <label className="block text-xs font-medium mb-1">Help Text</label>
1028
1031
  <input type="text" value={editingAttr.helpText ?? ''} onChange={(e) => setEditingAttr({ ...editingAttr, helpText: e.target.value || null })} className="w-full rounded-md border px-2 py-1.5 text-sm" />
1029
1032
  </div>
1033
+ {/* Reference attributes resolve to an FK UUID at run-time, so a
1034
+ free-text scalar default has no sensible meaning — omit the
1035
+ field entirely for them. */}
1036
+ {editingAttr.attributeType !== 'reference' && (
1037
+ <div>
1038
+ <label className="block text-xs font-medium mb-1">{t('cpq.specifications.defaultValue', 'Default Value')}</label>
1039
+ {editingAttr.attributeType === 'number' ? (
1040
+ <NumberInput
1041
+ value={typeof editingAttr.defaultValue === 'number' ? editingAttr.defaultValue : null}
1042
+ onChange={(n) => setEditingAttr({ ...editingAttr, defaultValue: n })}
1043
+ placeholder={t('cpq.specifications.noDefault', 'No default')}
1044
+ />
1045
+ ) : editingAttr.attributeType === 'boolean' ? (
1046
+ <select
1047
+ value={editingAttr.defaultValue === true ? 'true' : editingAttr.defaultValue === false ? 'false' : ''}
1048
+ onChange={(e) => setEditingAttr({ ...editingAttr, defaultValue: e.target.value === '' ? null : e.target.value === 'true' })}
1049
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
1050
+ >
1051
+ <option value="">{t('cpq.specifications.noDefault', 'No default')}</option>
1052
+ <option value="true">{t('cpq.specifications.booleanTrue', 'True')}</option>
1053
+ <option value="false">{t('cpq.specifications.booleanFalse', 'False')}</option>
1054
+ </select>
1055
+ ) : (editingAttr.attributeType === 'select' || editingAttr.attributeType === 'multi-select') ? (
1056
+ <>
1057
+ <select
1058
+ multiple={editingAttr.attributeType === 'multi-select'}
1059
+ value={
1060
+ editingAttr.attributeType === 'multi-select'
1061
+ ? (Array.isArray(editingAttr.defaultValue) ? editingAttr.defaultValue.map(String) : [])
1062
+ : (editingAttr.defaultValue == null ? '' : String(editingAttr.defaultValue))
1063
+ }
1064
+ onChange={(e) => {
1065
+ if (editingAttr.attributeType === 'multi-select') {
1066
+ const vals = Array.from(e.target.selectedOptions).map((o) => o.value).filter((v) => v !== '')
1067
+ setEditingAttr({ ...editingAttr, defaultValue: vals.length ? vals : null })
1068
+ } else {
1069
+ setEditingAttr({ ...editingAttr, defaultValue: e.target.value === '' ? null : e.target.value })
1070
+ }
1071
+ }}
1072
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
1073
+ >
1074
+ {/* `multiple` selects can't carry a placeholder row, so only the
1075
+ single-select variant gets an explicit "No default" option. */}
1076
+ {editingAttr.attributeType === 'select' && <option value="">{t('cpq.specifications.noDefault', 'No default')}</option>}
1077
+ {editingOptions
1078
+ .filter((o) => o.value.trim() !== '')
1079
+ .map((o) => (
1080
+ <option key={o.value} value={o.value}>
1081
+ {o.label || o.value}
1082
+ </option>
1083
+ ))}
1084
+ </select>
1085
+ {editingAttr.attributeType === 'multi-select' && (
1086
+ <p className="text-xs text-muted-foreground mt-1">
1087
+ {t('cpq.specifications.multiSelectDefaultHint', 'Ctrl/Cmd-click to select several, or deselect all to clear the default.')}
1088
+ </p>
1089
+ )}
1090
+ </>
1091
+ ) : (
1092
+ <input
1093
+ type="text"
1094
+ value={editingAttr.defaultValue == null ? '' : String(editingAttr.defaultValue)}
1095
+ onChange={(e) => setEditingAttr({ ...editingAttr, defaultValue: e.target.value === '' ? null : e.target.value })}
1096
+ placeholder={t('cpq.specifications.noDefault', 'No default')}
1097
+ className="w-full rounded-md border px-2 py-1.5 text-sm"
1098
+ />
1099
+ )}
1100
+ </div>
1101
+ )}
1030
1102
  {editingAttr.attributeType === 'reference' && (
1031
1103
  <>
1032
1104
  <div>
@@ -1,10 +1,89 @@
1
1
  import type { ModuleCli } from '@open-mercato/shared/modules/registry'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { seedCpqDemo } from './lib/seeds/demo'
2
5
 
3
- const seedCommand: ModuleCli = {
6
+ /**
7
+ * `mercato cpq seed [--reset] [--tenant <id>] [--org <id>]`
8
+ *
9
+ * Loads a minimal, neutral CPQ test catalog (product + specification +
10
+ * offering + charge) into a single tenant. With no `--tenant`/`--org` it
11
+ * resolves the first (primary) organization created by `mercato init`.
12
+ * `--reset` clears the seed's own rows first.
13
+ *
14
+ * For rich, vertical demo catalogs use `mercato demo_tenants seed --all`.
15
+ */
16
+
17
+ function parseArgs(rest: string[]): Record<string, string | boolean> {
18
+ const out: Record<string, string | boolean> = {}
19
+ for (let i = 0; i < rest.length; i++) {
20
+ const token = rest[i]
21
+ if (!token || !token.startsWith('--')) continue
22
+ const key = token.slice(2)
23
+ const next = rest[i + 1]
24
+ if (next === undefined || next.startsWith('--')) out[key] = true
25
+ else {
26
+ out[key] = next
27
+ i++
28
+ }
29
+ }
30
+ return out
31
+ }
32
+
33
+ async function resolveScope(
34
+ em: EntityManager,
35
+ args: Record<string, string | boolean>,
36
+ ): Promise<{ tenantId: string; organizationId: string } | null> {
37
+ const orgArg = typeof args.org === 'string' ? args.org : undefined
38
+ const tenantArg = typeof args.tenant === 'string' ? args.tenant : undefined
39
+
40
+ const { Organization } = await import('@open-mercato/core/modules/directory/data/entities')
41
+
42
+ if (orgArg) {
43
+ const org = await em.findOne(Organization, { id: orgArg } as never)
44
+ if (!org) return null
45
+ return { organizationId: org.id, tenantId: tenantArg ?? org.tenant.id }
46
+ }
47
+
48
+ const orgs = await em.find(
49
+ Organization,
50
+ { isActive: true } as never,
51
+ { orderBy: { createdAt: 'asc' } as never, limit: 5 },
52
+ )
53
+ if (orgs.length === 0) return null
54
+ const org = orgs[0]
55
+ if (orgs.length > 1) {
56
+ console.warn(
57
+ `[cpq seed] Multiple organizations found — defaulting to "${org.name}" (${org.id}). ` +
58
+ `Pass --org <id> to target another.`,
59
+ )
60
+ }
61
+ return { organizationId: org.id, tenantId: tenantArg ?? org.tenant.id }
62
+ }
63
+
64
+ const seed: ModuleCli = {
4
65
  command: 'seed',
5
- async run() {
6
- console.log('CPQ seed: no default seed data. Configure specifications, offerings, and pricing via the admin UI.')
66
+ async run(rest) {
67
+ const args = parseArgs(rest)
68
+ const reset = Boolean(args.reset)
69
+
70
+ const container = await createRequestContainer()
71
+ const em = container.resolve<EntityManager>('em')
72
+
73
+ const scope = await resolveScope(em, args)
74
+ if (!scope) {
75
+ console.error('[cpq seed] No organization found. Run "yarn initialize" first, or pass --org <id>.')
76
+ return
77
+ }
78
+
79
+ const res = await seedCpqDemo(em, scope, { reset })
80
+ console.log(
81
+ `[cpq seed] tenant=${scope.tenantId} org=${scope.organizationId}${reset ? ' (reset)' : ''}\n` +
82
+ ` ✓ products +${res.productsCreated}, specs +${res.specsCreated}, ` +
83
+ `offerings +${res.offeringsCreated}, charges +${res.chargesCreated} (existing rows left untouched)\n` +
84
+ ` → Backend · CPQ · Product Specifications / Product Offerings.`,
85
+ )
7
86
  },
8
87
  }
9
88
 
10
- export default [seedCommand]
89
+ export default [seed]
@@ -0,0 +1,49 @@
1
+ import { normalizeChargePricing } from '../charge-pricing'
2
+
3
+ // Pins the documented decision table for the legacy-disambiguation logic.
4
+ describe('normalizeChargePricing', () => {
5
+ it('legacy flat without a table → fixed / flat', () => {
6
+ expect(normalizeChargePricing({ pricingMethod: 'flat' })).toEqual({ model: 'flat', source: 'fixed' })
7
+ })
8
+
9
+ it('legacy flat with a pricingTableId → table / flat (preserves calculateFlat-via-table)', () => {
10
+ expect(normalizeChargePricing({ pricingMethod: 'flat', pricingTableId: 't1' })).toEqual({ model: 'flat', source: 'table' })
11
+ })
12
+
13
+ it('legacy per_unit → table / per_unit', () => {
14
+ expect(normalizeChargePricing({ pricingMethod: 'per_unit' })).toEqual({ model: 'per_unit', source: 'table' })
15
+ })
16
+
17
+ it('legacy tiered → table / tiered', () => {
18
+ expect(normalizeChargePricing({ pricingMethod: 'tiered' })).toEqual({ model: 'tiered', source: 'table' })
19
+ })
20
+
21
+ it('legacy fixed → fixed / flat', () => {
22
+ expect(normalizeChargePricing({ pricingMethod: 'fixed' })).toEqual({ model: 'flat', source: 'fixed' })
23
+ })
24
+
25
+ it('new split: chargeModel=volume + pricingMethod=table → table / volume', () => {
26
+ expect(normalizeChargePricing({ chargeModel: 'volume', pricingMethod: 'table' })).toEqual({ model: 'volume', source: 'table' })
27
+ })
28
+
29
+ it('new split: chargeModel=per_unit + pricingMethod=fixed → fixed / per_unit', () => {
30
+ expect(normalizeChargePricing({ chargeModel: 'per_unit', pricingMethod: 'fixed' })).toEqual({ model: 'per_unit', source: 'fixed' })
31
+ })
32
+
33
+ it('explicit chargeModel wins over a legacy pricingMethod value', () => {
34
+ expect(normalizeChargePricing({ chargeModel: 'volume', pricingMethod: 'tiered' })).toEqual({ model: 'volume', source: 'table' })
35
+ })
36
+
37
+ it('chargeModel only (no pricingMethod), pricingTableId set → table / <model>', () => {
38
+ expect(normalizeChargePricing({ chargeModel: 'volume', pricingTableId: 't1' })).toEqual({ model: 'volume', source: 'table' })
39
+ })
40
+
41
+ it('all empty / null → fixed / flat (default)', () => {
42
+ expect(normalizeChargePricing({})).toEqual({ model: 'flat', source: 'fixed' })
43
+ expect(normalizeChargePricing({ chargeModel: null, pricingMethod: null })).toEqual({ model: 'flat', source: 'fixed' })
44
+ })
45
+
46
+ it('is case-insensitive on both axes', () => {
47
+ expect(normalizeChargePricing({ chargeModel: 'TIERED', pricingMethod: 'TABLE' })).toEqual({ model: 'tiered', source: 'table' })
48
+ })
49
+ })