@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
@@ -6,12 +6,27 @@ import { useParams, useRouter } from "next/navigation";
6
6
  import { Alert } from "@open-mercato/ui/primitives/alert";
7
7
  import { Button } from "@open-mercato/ui/primitives/button";
8
8
  import { Tag } from "@open-mercato/ui/primitives/tag";
9
+ import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
9
10
  import { NumberInput } from "../../../../components/NumberInput.js";
11
+ import { normalizeChargePricing } from "../../../../data/charge-pricing.js";
10
12
  import {
11
13
  chargeTypeMap,
12
14
  formatStatusLabel,
13
15
  lifecycleStatusMap
14
16
  } from "../../../../components/statusMaps.js";
17
+ async function fetchPricingTables() {
18
+ try {
19
+ const { ok, result } = await apiCall("/api/cpq/pricing-tables?pageSize=100");
20
+ if (!ok) {
21
+ console.warn("[cpq] Failed to load pricing tables for the charges list");
22
+ return [];
23
+ }
24
+ return result?.items ?? [];
25
+ } catch (err) {
26
+ console.warn("[cpq] Failed to load pricing tables for the charges list:", err);
27
+ return [];
28
+ }
29
+ }
15
30
  const EMPTY_FORM = {
16
31
  code: "",
17
32
  name: "",
@@ -28,7 +43,8 @@ const EMPTY_CHARGE = {
28
43
  name: "",
29
44
  description: null,
30
45
  chargeType: "mrc",
31
- pricingMethod: "flat",
46
+ chargeModel: "flat",
47
+ pricingMethod: "fixed",
32
48
  pricingTableId: null,
33
49
  priceColumnKey: null,
34
50
  fixedPrice: null,
@@ -38,26 +54,30 @@ const EMPTY_CHARGE = {
38
54
  sortOrder: 0,
39
55
  isActive: true
40
56
  };
41
- const PRICING_METHOD_LABELS = {
42
- flat: "flat price",
43
- fixed: "flat price",
44
- // legacy alias — see validators.ts
45
- per_unit: "per unit",
46
- tiered: "tiered"
57
+ const CHARGE_MODEL_LABELS = {
58
+ flat: "Flat Fee",
59
+ per_unit: "Per Unit",
60
+ volume: "Volume-based",
61
+ tiered: "Tiered"
62
+ };
63
+ const PRICING_SOURCE_LABELS = {
64
+ fixed: "Fixed Price",
65
+ table: "Table Lookup"
47
66
  };
48
67
  function isChargeComplete(charge) {
49
68
  return chargeMissingFields(charge).length === 0;
50
69
  }
51
70
  function chargeMissingFields(charge) {
52
- const method = charge.pricingMethod === "fixed" ? "flat" : charge.pricingMethod;
71
+ const { model, source } = normalizeChargePricing(charge);
53
72
  const missing = [];
54
- if (method === "flat") {
73
+ if (source === "fixed") {
55
74
  if (!charge.fixedPrice) missing.push("Fixed Price");
56
75
  if (!charge.currencyCode) missing.push("Currency");
57
- } else if (method === "per_unit" || method === "tiered") {
76
+ if (model === "per_unit" && !charge.quantityAttributeCode) missing.push("Quantity Attribute");
77
+ } else {
58
78
  if (!charge.pricingTableId) missing.push("Pricing Table");
59
79
  if (!charge.priceColumnKey) missing.push("Price Column");
60
- if (!charge.quantityAttributeCode) missing.push("Quantity Attribute");
80
+ if (model !== "flat" && !charge.quantityAttributeCode) missing.push("Quantity Attribute");
61
81
  }
62
82
  return missing;
63
83
  }
@@ -118,6 +138,10 @@ function OfferingDetailPage(props) {
118
138
  setCharges(data.charges ?? []);
119
139
  setSpecDetail(data.specification ?? null);
120
140
  setComponents(data.components ?? []);
141
+ if ((data.charges ?? []).some((c) => c.pricingTableId)) {
142
+ const tables = await fetchPricingTables();
143
+ if (!cancelled) setPricingTables(tables);
144
+ }
121
145
  if (data.specId) {
122
146
  const attrRes = await fetch(`/api/cpq/product-attributes?specId=${encodeURIComponent(data.specId)}&pageSize=200`);
123
147
  if (attrRes.ok) {
@@ -221,15 +245,15 @@ function OfferingDetailPage(props) {
221
245
  };
222
246
  const loadPricingTables = async () => {
223
247
  if (pricingTables.length > 0) return;
224
- const res = await fetch("/api/cpq/pricing-tables?pageSize=200");
225
- if (res.ok) {
226
- const data = await res.json();
227
- setPricingTables(data.items ?? []);
228
- }
248
+ setPricingTables(await fetchPricingTables());
229
249
  };
230
250
  const startEditCharge = (charge) => {
231
- const editing = charge ? { ...charge } : { ...EMPTY_CHARGE };
232
- setEditingCharge(editing);
251
+ if (charge) {
252
+ const { model, source } = normalizeChargePricing(charge);
253
+ setEditingCharge({ ...charge, chargeModel: model, pricingMethod: source });
254
+ } else {
255
+ setEditingCharge({ ...EMPTY_CHARGE });
256
+ }
233
257
  loadPricingTables();
234
258
  };
235
259
  const saveCharge = async () => {
@@ -545,22 +569,56 @@ function OfferingDetailPage(props) {
545
569
  /* @__PURE__ */ jsx("option", { value: "usage", children: "Usage" })
546
570
  ] })
547
571
  ] }),
572
+ /* @__PURE__ */ jsxs("div", { children: [
573
+ /* @__PURE__ */ jsx("label", { className: "block text-xs font-medium mb-1", children: "Charge Model" }),
574
+ /* @__PURE__ */ jsxs(
575
+ "select",
576
+ {
577
+ value: editingCharge.chargeModel ?? "flat",
578
+ onChange: (e) => {
579
+ const model = e.target.value;
580
+ const next = { ...editingCharge, chargeModel: model };
581
+ if ((model === "volume" || model === "tiered") && editingCharge.pricingMethod !== "table") {
582
+ next.pricingMethod = "table";
583
+ next.fixedPrice = null;
584
+ }
585
+ if (model === "flat") next.quantityAttributeCode = null;
586
+ setEditingCharge(next);
587
+ },
588
+ className: "w-full rounded-md border px-2 py-1.5 text-sm",
589
+ children: [
590
+ /* @__PURE__ */ jsx("option", { value: "flat", children: "Flat Fee" }),
591
+ /* @__PURE__ */ jsx("option", { value: "per_unit", children: "Per Unit" }),
592
+ /* @__PURE__ */ jsx("option", { value: "volume", children: "Volume-based" }),
593
+ /* @__PURE__ */ jsx("option", { value: "tiered", children: "Tiered" })
594
+ ]
595
+ }
596
+ )
597
+ ] }),
548
598
  /* @__PURE__ */ jsxs("div", { children: [
549
599
  /* @__PURE__ */ jsx("label", { className: "block text-xs font-medium mb-1", children: "Pricing Method" }),
550
- /* @__PURE__ */ jsxs("select", { value: editingCharge.pricingMethod, onChange: (e) => {
551
- const method = e.target.value;
552
- if (method === "flat") {
553
- setEditingCharge({ ...editingCharge, pricingMethod: method, pricingTableId: null, priceColumnKey: null, quantityAttributeCode: null });
554
- } else {
555
- setEditingCharge({ ...editingCharge, pricingMethod: method, fixedPrice: null });
600
+ /* @__PURE__ */ jsxs(
601
+ "select",
602
+ {
603
+ value: editingCharge.pricingMethod,
604
+ onChange: (e) => {
605
+ const source = e.target.value;
606
+ if (source === "fixed") {
607
+ const keepQty = editingCharge.chargeModel === "per_unit";
608
+ setEditingCharge({ ...editingCharge, pricingMethod: source, pricingTableId: null, priceColumnKey: null, ...keepQty ? {} : { quantityAttributeCode: null } });
609
+ } else {
610
+ setEditingCharge({ ...editingCharge, pricingMethod: source, fixedPrice: null });
611
+ }
612
+ },
613
+ className: "w-full rounded-md border px-2 py-1.5 text-sm",
614
+ children: [
615
+ editingCharge.chargeModel !== "volume" && editingCharge.chargeModel !== "tiered" && /* @__PURE__ */ jsx("option", { value: "fixed", children: "Fixed Price" }),
616
+ /* @__PURE__ */ jsx("option", { value: "table", children: "Table Lookup" })
617
+ ]
556
618
  }
557
- }, className: "w-full rounded-md border px-2 py-1.5 text-sm", children: [
558
- /* @__PURE__ */ jsx("option", { value: "flat", children: "Flat Price" }),
559
- /* @__PURE__ */ jsx("option", { value: "per_unit", children: "Per Unit (table lookup)" }),
560
- /* @__PURE__ */ jsx("option", { value: "tiered", children: "Tiered (table lookup)" })
561
- ] })
619
+ )
562
620
  ] }),
563
- editingCharge.pricingMethod === "flat" || editingCharge.pricingMethod === "fixed" ? /* @__PURE__ */ jsxs(Fragment, { children: [
621
+ editingCharge.pricingMethod === "fixed" ? /* @__PURE__ */ jsxs(Fragment, { children: [
564
622
  /* @__PURE__ */ jsxs("div", { children: [
565
623
  /* @__PURE__ */ jsxs("label", { className: "block text-xs font-medium mb-1", children: [
566
624
  "Fixed Price ",
@@ -595,9 +653,7 @@ function OfferingDetailPage(props) {
595
653
  " ",
596
654
  /* @__PURE__ */ jsx("a", { href: "/backend/cpq/pricing-tables", className: "text-primary hover:underline", children: "create one" }),
597
655
  " ",
598
- "before using ",
599
- editingCharge.pricingMethod,
600
- " pricing."
656
+ "before using Table Lookup pricing."
601
657
  ] })
602
658
  ] }),
603
659
  /* @__PURE__ */ jsxs("div", { children: [
@@ -609,29 +665,29 @@ function OfferingDetailPage(props) {
609
665
  /* @__PURE__ */ jsx("option", { value: "", children: "Select column..." }),
610
666
  selectedPricingTable?.priceColumns.map((col) => /* @__PURE__ */ jsx("option", { value: col.key, children: col.label }, col.key))
611
667
  ] })
668
+ ] })
669
+ ] }),
670
+ (editingCharge.pricingMethod === "table" && editingCharge.chargeModel !== "flat" || editingCharge.pricingMethod === "fixed" && editingCharge.chargeModel === "per_unit") && /* @__PURE__ */ jsxs("div", { children: [
671
+ /* @__PURE__ */ jsxs("label", { className: "block text-xs font-medium mb-1", children: [
672
+ "Quantity Attribute ",
673
+ /* @__PURE__ */ jsx("span", { className: "text-destructive", children: "*" })
612
674
  ] }),
613
- /* @__PURE__ */ jsxs("div", { children: [
614
- /* @__PURE__ */ jsxs("label", { className: "block text-xs font-medium mb-1", children: [
615
- "Quantity Attribute ",
616
- /* @__PURE__ */ jsx("span", { className: "text-destructive", children: "*" })
617
- ] }),
618
- /* @__PURE__ */ jsxs("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", children: [
619
- /* @__PURE__ */ jsx("option", { value: "", children: "Select attribute..." }),
620
- attributes.filter((a) => a.attributeType === "number").map((a) => /* @__PURE__ */ jsxs("option", { value: a.code, children: [
621
- a.name,
622
- " (",
623
- a.code,
624
- ")"
625
- ] }, a.code))
626
- ] }),
627
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mt-0.5", children: "Attribute whose value drives the quantity for pricing" }),
628
- attributes.filter((a) => a.attributeType === "number").length === 0 && form.specId && /* @__PURE__ */ jsxs("p", { className: "text-xs text-warning mt-0.5", children: [
629
- "Spec has no number attributes \u2014",
630
- " ",
631
- /* @__PURE__ */ jsx("a", { href: `/backend/cpq/specifications/${form.specId}`, className: "text-primary hover:underline", children: "add one to the spec" }),
632
- " ",
633
- "first."
634
- ] })
675
+ /* @__PURE__ */ jsxs("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", children: [
676
+ /* @__PURE__ */ jsx("option", { value: "", children: "Select attribute..." }),
677
+ attributes.filter((a) => a.attributeType === "number").map((a) => /* @__PURE__ */ jsxs("option", { value: a.code, children: [
678
+ a.name,
679
+ " (",
680
+ a.code,
681
+ ")"
682
+ ] }, a.code))
683
+ ] }),
684
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mt-0.5", children: "Attribute whose value drives the quantity for pricing" }),
685
+ attributes.filter((a) => a.attributeType === "number").length === 0 && form.specId && /* @__PURE__ */ jsxs("p", { className: "text-xs text-warning mt-0.5", children: [
686
+ "Spec has no number attributes \u2014",
687
+ " ",
688
+ /* @__PURE__ */ jsx("a", { href: `/backend/cpq/specifications/${form.specId}`, className: "text-primary hover:underline", children: "add one to the spec" }),
689
+ " ",
690
+ "first."
635
691
  ] })
636
692
  ] }),
637
693
  /* @__PURE__ */ jsxs("div", { children: [
@@ -687,7 +743,7 @@ function OfferingDetailPage(props) {
687
743
  const incomplete = !isChargeComplete(charge);
688
744
  const tableRef = charge.pricingTableId ? pricingTables.find((pt) => pt.id === charge.pricingTableId) : null;
689
745
  const qtyAttr = charge.quantityAttributeCode ? attributes.find((a) => a.code === charge.quantityAttributeCode) : null;
690
- const isFlat = charge.pricingMethod === "flat" || charge.pricingMethod === "fixed";
746
+ const { model, source } = normalizeChargePricing(charge);
691
747
  return /* @__PURE__ */ jsxs("tr", { className: "border-b", children: [
692
748
  /* @__PURE__ */ jsx("td", { className: "px-4 py-3 font-mono text-xs", children: charge.code }),
693
749
  /* @__PURE__ */ jsxs("td", { className: "px-4 py-3", children: [
@@ -695,14 +751,27 @@ function OfferingDetailPage(props) {
695
751
  incomplete && /* @__PURE__ */ jsx(Tag, { variant: "error", className: "ml-2 px-2 text-[10px]", dot: true, children: "Incomplete" })
696
752
  ] }),
697
753
  /* @__PURE__ */ jsx("td", { className: "px-4 py-3", children: /* @__PURE__ */ jsx(Tag, { variant: chargeTypeMap[charge.chargeType] ?? "neutral", className: "px-2 text-xs", children: charge.chargeType.toUpperCase() }) }),
698
- /* @__PURE__ */ jsx("td", { className: "px-4 py-3 text-xs", children: PRICING_METHOD_LABELS[charge.pricingMethod] ?? charge.pricingMethod }),
699
- /* @__PURE__ */ jsx("td", { className: "px-4 py-3 text-xs", children: isFlat ? charge.fixedPrice != null ? `${charge.currencyCode ?? "USD"} ${charge.fixedPrice}` : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-0.5", children: [
754
+ /* @__PURE__ */ jsxs("td", { className: "px-4 py-3 text-xs", children: [
755
+ CHARGE_MODEL_LABELS[model] ?? model,
756
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
757
+ " \xB7 ",
758
+ PRICING_SOURCE_LABELS[source] ?? source
759
+ ] })
760
+ ] }),
761
+ /* @__PURE__ */ jsx("td", { className: "px-4 py-3 text-xs", children: source === "fixed" ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-0.5", children: [
762
+ charge.fixedPrice != null ? /* @__PURE__ */ jsx("span", { children: `${charge.currencyCode ?? "USD"} ${charge.fixedPrice}` }) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }),
763
+ model === "per_unit" && /* @__PURE__ */ jsxs("span", { children: [
764
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\xD7 Qty:" }),
765
+ " ",
766
+ qtyAttr ? `${qtyAttr.name} (${qtyAttr.code})` : /* @__PURE__ */ jsx("span", { className: "text-destructive", children: "missing" })
767
+ ] })
768
+ ] }) : /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-0.5", children: [
700
769
  /* @__PURE__ */ jsxs("span", { children: [
701
770
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "Table:" }),
702
771
  " ",
703
772
  tableRef ? `${tableRef.name} \u2192 ${charge.priceColumnKey ?? "?"}` : /* @__PURE__ */ jsx("span", { className: "text-destructive", children: "missing" })
704
773
  ] }),
705
- /* @__PURE__ */ jsxs("span", { children: [
774
+ model !== "flat" && /* @__PURE__ */ jsxs("span", { children: [
706
775
  /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\xD7 Qty:" }),
707
776
  " ",
708
777
  qtyAttr ? `${qtyAttr.name} (${qtyAttr.code})` : /* @__PURE__ */ jsx("span", { className: "text-destructive", children: "missing" })