@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.
- package/CHANGELOG.md +62 -0
- package/MIGRATION.md +34 -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 +11 -9
- 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
|
@@ -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
|
-
|
|
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
|
|
42
|
-
flat: "
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
71
|
+
const { model, source } = normalizeChargePricing(charge);
|
|
53
72
|
const missing = [];
|
|
54
|
-
if (
|
|
73
|
+
if (source === "fixed") {
|
|
55
74
|
if (!charge.fixedPrice) missing.push("Fixed Price");
|
|
56
75
|
if (!charge.currencyCode) missing.push("Currency");
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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 === "
|
|
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("
|
|
614
|
-
/* @__PURE__ */
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
|
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__ */
|
|
699
|
-
|
|
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" })
|