@zuplo/zudoku-plugin-monetization 0.0.32 → 0.0.34

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 (2) hide show
  1. package/dist/index.mjs +347 -199
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -8,8 +8,8 @@ import { Link as Link$1, Outlet, useLocation, useNavigate, useSearchParams } fro
8
8
  import { Alert, AlertAction, AlertDescription, AlertTitle } from "zudoku/ui/Alert";
9
9
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "zudoku/ui/Card";
10
10
  import { Separator } from "zudoku/ui/Separator";
11
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
12
11
  import { parse } from "tinyduration";
12
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
13
13
  import { Button as Button$1 } from "zudoku/ui/Button";
14
14
  import { Skeleton } from "zudoku/ui/Skeleton";
15
15
  import { DismissibleAlert, DismissibleAlertAction } from "zudoku/ui/DismissibleAlert";
@@ -23,72 +23,6 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
23
23
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "zudoku/ui/Dialog";
24
24
  import { Input } from "zudoku/ui/Input";
25
25
  import { Progress } from "zudoku/ui/Progress";
26
- //#region src/components/FeatureItem.tsx
27
- const FeatureItem = ({ feature, className }) => {
28
- return /* @__PURE__ */ jsxs("div", {
29
- className: cn("flex items-start gap-2", className),
30
- children: [/* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-primary shrink-0 mt-0.5" }), /* @__PURE__ */ jsx("div", {
31
- className: "text-sm",
32
- children: feature.value ? /* @__PURE__ */ jsxs(Fragment, { children: [
33
- /* @__PURE__ */ jsxs("span", {
34
- className: "font-medium",
35
- children: [feature.name, ":"]
36
- }),
37
- " ",
38
- feature.value
39
- ] }) : feature.name
40
- })]
41
- });
42
- };
43
- //#endregion
44
- //#region src/components/QuotaItem.tsx
45
- const QuotaItem = ({ quota, className }) => {
46
- return /* @__PURE__ */ jsxs("div", {
47
- className: cn("flex items-start gap-2", className),
48
- children: [/* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-primary shrink-0 mt-0.5" }), /* @__PURE__ */ jsxs("div", {
49
- className: "text-sm",
50
- children: [
51
- /* @__PURE__ */ jsxs("span", {
52
- className: "font-medium",
53
- children: [quota.name, ":"]
54
- }),
55
- " ",
56
- quota.limit.toLocaleString(),
57
- " / ",
58
- quota.period,
59
- quota.overagePrice && /* @__PURE__ */ jsxs("div", {
60
- className: "text-xs text-muted-foreground mt-0.5",
61
- children: [
62
- "+",
63
- quota.overagePrice,
64
- " after quota"
65
- ]
66
- })
67
- ]
68
- })]
69
- });
70
- };
71
- //#endregion
72
- //#region src/hooks/useDeploymentName.ts
73
- const useDeploymentName = () => {
74
- const deploymentName = useZudoku().env.ZUPLO_PUBLIC_DEPLOYMENT_NAME;
75
- if (!deploymentName) throw new Error("ZUPLO_PUBLIC_DEPLOYMENT_NAME is not set");
76
- return deploymentName;
77
- };
78
- //#endregion
79
- //#region src/hooks/usePurchaseDetails.ts
80
- const usePurchaseDetails = (planId) => {
81
- const zudoku = useZudoku();
82
- return useSuspenseQuery({
83
- queryKey: [`/v3/zudoku-metering/${useDeploymentName()}/plans/${planId}/purchase-details`],
84
- meta: { context: zudoku }
85
- });
86
- };
87
- //#endregion
88
- //#region src/MonetizationContext.tsx
89
- const MonetizationContext = createContext({});
90
- const useMonetizationConfig = () => use(MonetizationContext);
91
- //#endregion
92
26
  //#region src/utils/formatDuration.ts
93
27
  const formatDuration = (iso) => {
94
28
  try {
@@ -165,6 +99,31 @@ const formatMinorCurrencyAmount = (amountInMinorUnits, currency) => {
165
99
  }).format(amountInMinorUnits / divisor);
166
100
  };
167
101
  //#endregion
102
+ //#region src/utils/formatTieredPriceBreakdown.ts
103
+ const parseAmount = (value) => {
104
+ if (!value) return;
105
+ const parsed = Number.parseFloat(value);
106
+ return Number.isFinite(parsed) ? parsed : void 0;
107
+ };
108
+ const formatTieredPriceBreakdown = (opts) => {
109
+ const { tiers, currency, unitLabel, includedLabel, omitIncludedUpToAmount } = opts;
110
+ if (!tiers || tiers.length <= 1) return;
111
+ const lines = [];
112
+ let lastUpTo;
113
+ for (const tier of tiers) {
114
+ const upTo = parseAmount(tier.upToAmount);
115
+ const unit = parseAmount(tier.unitPriceAmount) ?? 0;
116
+ const flat = parseAmount(tier.flatPriceAmount) ?? 0;
117
+ const prefix = upTo != null ? `Up to ${upTo.toLocaleString("en-US")}` : lastUpTo != null ? `Over ${lastUpTo.toLocaleString("en-US")}` : `Per ${unitLabel}`;
118
+ const unitPart = unit > 0 ? `${formatPrice(unit, currency)}/${unitLabel}` : includedLabel;
119
+ const flatPart = flat > 0 ? ` + ${formatPrice(flat, currency)} base` : "";
120
+ const line = `${prefix}: ${unitPart}${flatPart}`;
121
+ if (omitIncludedUpToAmount != null && upTo != null && upTo === omitIncludedUpToAmount && unitPart === includedLabel && flatPart === "") {} else lines.push(line);
122
+ if (upTo != null) lastUpTo = upTo;
123
+ }
124
+ return lines.length > 0 ? lines : void 0;
125
+ };
126
+ //#endregion
168
127
  //#region src/utils/categorizeRateCards.ts
169
128
  const categorizeRateCards = (rateCards, options) => {
170
129
  const { currency, units, planBillingCadence } = options ?? {};
@@ -175,20 +134,30 @@ const categorizeRateCards = (rateCards, options) => {
175
134
  if (!et) continue;
176
135
  if (et.type === "metered" && et.issueAfterReset != null) {
177
136
  let overagePrice;
178
- if (et.isSoftLimit !== false && rc.price?.type === "tiered" && rc.price.tiers) {
137
+ let tierPrices;
138
+ if (rc.price?.type === "tiered" && rc.price.tiers) {
139
+ const unitLabel = units?.[rc.key] ?? units?.[rc.featureKey ?? ""] ?? "unit";
140
+ tierPrices = formatTieredPriceBreakdown({
141
+ tiers: rc.price.tiers.map((t) => ({
142
+ upToAmount: t.upToAmount,
143
+ unitPriceAmount: t.unitPrice?.amount,
144
+ flatPriceAmount: t.flatPrice?.amount
145
+ })),
146
+ currency,
147
+ unitLabel,
148
+ includedLabel: "Included",
149
+ omitIncludedUpToAmount: et.issueAfterReset
150
+ });
179
151
  const overageTier = rc.price.tiers.find((t) => t.unitPrice?.amount && parseFloat(t.unitPrice.amount) > 0);
180
- if (overageTier?.unitPrice) {
181
- const amount = parseFloat(overageTier.unitPrice.amount);
182
- const unitLabel = units?.[rc.key] ?? units?.[rc.featureKey ?? ""] ?? "unit";
183
- overagePrice = `${formatPrice(amount, currency)}/${unitLabel}`;
184
- }
152
+ if (et.isSoftLimit !== false && overageTier?.unitPrice) overagePrice = `${formatPrice(parseFloat(overageTier.unitPrice.amount), currency)}/${unitLabel}`;
185
153
  }
186
154
  quotas.push({
187
155
  key: rc.featureKey ?? rc.key,
188
156
  name: rc.name,
189
157
  limit: et.issueAfterReset,
190
158
  period: rc.billingCadence ? formatDuration(rc.billingCadence) : planBillingCadence ? formatDuration(planBillingCadence) : "month",
191
- overagePrice
159
+ overagePrice,
160
+ tierPrices
192
161
  });
193
162
  } else if (et.type === "boolean") features.push({
194
163
  key: rc.featureKey ?? rc.key,
@@ -214,6 +183,123 @@ const categorizeRateCards = (rateCards, options) => {
214
183
  };
215
184
  };
216
185
  //#endregion
186
+ //#region src/components/FeatureItem.tsx
187
+ const FeatureItem = ({ feature, className }) => {
188
+ return /* @__PURE__ */ jsxs("div", {
189
+ className: cn("flex items-start gap-2", className),
190
+ children: [/* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-primary shrink-0 mt-0.5" }), /* @__PURE__ */ jsx("div", {
191
+ className: "text-sm",
192
+ children: feature.value ? /* @__PURE__ */ jsxs(Fragment, { children: [
193
+ /* @__PURE__ */ jsxs("span", {
194
+ className: "font-medium",
195
+ children: [feature.name, ":"]
196
+ }),
197
+ " ",
198
+ feature.value
199
+ ] }) : feature.name
200
+ })]
201
+ });
202
+ };
203
+ //#endregion
204
+ //#region src/components/QuotaItem.tsx
205
+ const QuotaItem = ({ quota, className }) => {
206
+ return /* @__PURE__ */ jsxs("div", {
207
+ className: cn("flex items-start gap-2", className),
208
+ children: [/* @__PURE__ */ jsx(CheckIcon, { className: "w-4 h-4 text-primary shrink-0 mt-0.5" }), /* @__PURE__ */ jsxs("div", {
209
+ className: "text-sm",
210
+ children: [
211
+ /* @__PURE__ */ jsxs("span", {
212
+ className: "font-medium",
213
+ children: [quota.name, ":"]
214
+ }),
215
+ " ",
216
+ quota.limit.toLocaleString(),
217
+ " / ",
218
+ quota.period,
219
+ quota.tierPrices && quota.tierPrices.length > 0 && /* @__PURE__ */ jsx("ul", {
220
+ className: "text-xs text-muted-foreground mt-1 space-y-0.5",
221
+ children: quota.tierPrices.map((line) => /* @__PURE__ */ jsx("li", { children: line }, line))
222
+ }),
223
+ quota.overagePrice && /* @__PURE__ */ jsxs("div", {
224
+ className: "text-xs text-muted-foreground mt-0.5",
225
+ children: [
226
+ "+",
227
+ quota.overagePrice,
228
+ " after quota"
229
+ ]
230
+ })
231
+ ]
232
+ })]
233
+ });
234
+ };
235
+ //#endregion
236
+ //#region src/components/PlanEntitlements.tsx
237
+ const PhaseSection = ({ phase, currency, showName, billingCadence, units, itemClassName }) => {
238
+ const { quotas, features } = categorizeRateCards(phase.rateCards, {
239
+ currency,
240
+ units,
241
+ planBillingCadence: billingCadence
242
+ });
243
+ if (quotas.length === 0 && features.length === 0) return null;
244
+ return /* @__PURE__ */ jsxs("div", {
245
+ className: "space-y-2",
246
+ children: [
247
+ showName && /* @__PURE__ */ jsxs("div", {
248
+ className: "text-sm font-medium text-card-foreground",
249
+ children: [phase.name, phase.duration && /* @__PURE__ */ jsxs("span", {
250
+ className: "text-muted-foreground font-normal",
251
+ children: [
252
+ " ",
253
+ "— ",
254
+ formatDuration(phase.duration)
255
+ ]
256
+ })]
257
+ }),
258
+ quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, {
259
+ quota,
260
+ className: itemClassName
261
+ }, quota.key)),
262
+ features.map((feature) => /* @__PURE__ */ jsx(FeatureItem, {
263
+ feature,
264
+ className: itemClassName
265
+ }, feature.key))
266
+ ]
267
+ });
268
+ };
269
+ const PlanEntitlements = ({ phases, currency, billingCadence, units, itemClassName }) => {
270
+ return /* @__PURE__ */ jsx("div", {
271
+ className: "space-y-4",
272
+ children: phases.map((phase, idx) => /* @__PURE__ */ jsx(PhaseSection, {
273
+ phase,
274
+ currency,
275
+ showName: phases.length > 1,
276
+ billingCadence,
277
+ units,
278
+ itemClassName
279
+ }, phase.key ?? String(idx)))
280
+ });
281
+ };
282
+ //#endregion
283
+ //#region src/hooks/useDeploymentName.ts
284
+ const useDeploymentName = () => {
285
+ const deploymentName = useZudoku().env.ZUPLO_PUBLIC_DEPLOYMENT_NAME;
286
+ if (!deploymentName) throw new Error("ZUPLO_PUBLIC_DEPLOYMENT_NAME is not set");
287
+ return deploymentName;
288
+ };
289
+ //#endregion
290
+ //#region src/hooks/usePurchaseDetails.ts
291
+ const usePurchaseDetails = (planId) => {
292
+ const zudoku = useZudoku();
293
+ return useSuspenseQuery({
294
+ queryKey: [`/v3/zudoku-metering/${useDeploymentName()}/plans/${planId}/purchase-details`],
295
+ meta: { context: zudoku }
296
+ });
297
+ };
298
+ //#endregion
299
+ //#region src/MonetizationContext.tsx
300
+ const MonetizationContext = createContext({});
301
+ const useMonetizationConfig = () => use(MonetizationContext);
302
+ //#endregion
217
303
  //#region src/utils/formatBillingCycle.ts
218
304
  const formatBillingCycle = (duration) => {
219
305
  if (duration === "month") return "monthly";
@@ -333,12 +419,6 @@ const CheckoutConfirmPage = () => {
333
419
  const taxAmount = getTaxAmountFromPurchaseDetails(purchaseDetails.data);
334
420
  const taxLabel = getTaxLabelFromPurchaseDetails(purchaseDetails.data);
335
421
  const taxInclusive = isTaxInclusiveFromPurchaseDetails(purchaseDetails.data);
336
- const rateCards = selectedPlan?.phases.at(-1)?.rateCards;
337
- const { quotas, features } = categorizeRateCards(rateCards ?? [], {
338
- currency: selectedPlan?.currency,
339
- units: pricing?.units,
340
- planBillingCadence: selectedPlan?.billingCadence
341
- });
342
422
  const price = selectedPlan ? getPriceFromPlan(selectedPlan) : null;
343
423
  const billingCycle = selectedPlan?.billingCadence ? formatDuration(selectedPlan.billingCadence) : null;
344
424
  const createSubscriptionMutation = useMutation({
@@ -434,15 +514,12 @@ const CheckoutConfirmPage = () => {
434
514
  className: "text-sm font-medium mb-3 mt-3",
435
515
  children: "What's included:"
436
516
  }),
437
- /* @__PURE__ */ jsxs("div", {
438
- className: "grid grid-cols-2 gap-2 text-muted-foreground",
439
- children: [quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, {
440
- quota,
441
- className: "text-muted-foreground"
442
- }, quota.key)), features.map((feature) => /* @__PURE__ */ jsx(FeatureItem, {
443
- feature,
444
- className: "text-muted-foreground"
445
- }, feature.key))]
517
+ /* @__PURE__ */ jsx(PlanEntitlements, {
518
+ phases: selectedPlan.phases,
519
+ currency: selectedPlan.currency,
520
+ billingCadence: selectedPlan.billingCadence,
521
+ units: pricing?.units,
522
+ itemClassName: "text-muted-foreground"
446
523
  })
447
524
  ] })]
448
525
  }),
@@ -641,41 +718,46 @@ const usePlans = () => {
641
718
  });
642
719
  };
643
720
  //#endregion
644
- //#region src/pages/pricing/PricingCard.tsx
645
- const PhaseSection = ({ phase, currency, showName, billingCadence }) => {
646
- const { pricing } = useMonetizationConfig();
647
- const { quotas, features } = categorizeRateCards(phase.rateCards, {
648
- currency,
649
- units: pricing?.units,
650
- planBillingCadence: billingCadence
651
- });
652
- if (quotas.length === 0 && features.length === 0) return null;
653
- return /* @__PURE__ */ jsxs("div", {
654
- className: "space-y-2",
655
- children: [
656
- showName && /* @__PURE__ */ jsxs("div", {
657
- className: "text-sm font-medium text-card-foreground",
658
- children: [phase.name, phase.duration && /* @__PURE__ */ jsxs("span", {
659
- className: "text-muted-foreground font-normal",
660
- children: [
661
- " ",
662
- "— ",
663
- formatDuration(phase.duration)
664
- ]
665
- })]
666
- }),
667
- quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, { quota }, quota.key)),
668
- features.map((feature) => /* @__PURE__ */ jsx(FeatureItem, { feature }, feature.key))
669
- ]
670
- });
721
+ //#region src/utils/pricingTaxLegend.ts
722
+ const normalizeTaxBehavior = (behavior) => {
723
+ switch (behavior.trim().toLowerCase()) {
724
+ case "exclusive":
725
+ case "tax_exclusive": return "exclusive";
726
+ case "inclusive":
727
+ case "tax_inclusive": return "inclusive";
728
+ default: return "unspecified";
729
+ }
730
+ };
731
+ const planHasDefaultTaxBehavior = (plan) => {
732
+ const behavior = plan.defaultTaxConfig?.behavior;
733
+ return typeof behavior === "string" && behavior.trim().length > 0;
734
+ };
735
+ const collectDefaultTaxBehaviors = (plan) => {
736
+ const behavior = plan.defaultTaxConfig?.behavior;
737
+ return typeof behavior === "string" && behavior.trim().length > 0 ? normalizeTaxBehavior(behavior) : "unspecified";
738
+ };
739
+ const taxBehaviorLegendSentence = (behavior) => {
740
+ switch (normalizeTaxBehavior(behavior)) {
741
+ case "exclusive": return "Prices exclude tax; taxes may be added at checkout if applicable.";
742
+ case "inclusive": return "Prices include tax where applicable.";
743
+ default: return;
744
+ }
745
+ };
746
+ const subscriptionTaxLegendSentence = (behavior) => {
747
+ switch (normalizeTaxBehavior(behavior)) {
748
+ case "exclusive": return "Price excludes tax; taxes may be added on invoice if applicable.";
749
+ case "inclusive": return "Price includes tax where applicable.";
750
+ default: return;
751
+ }
671
752
  };
753
+ //#endregion
754
+ //#region src/pages/pricing/PricingCard.tsx
672
755
  const PricingCard = ({ plan, isPopular = false, isSubscribed = false }) => {
673
756
  const { pricing } = useMonetizationConfig();
674
757
  if (plan.phases.length === 0) return null;
675
758
  const price = getPriceFromPlan(plan);
676
759
  const isFree = price.monthly === 0;
677
760
  const isCustom = plan.metadata?.isCustom === true;
678
- const hasMultiplePhases = plan.phases.length > 1;
679
761
  const billingInterval = formatDuration(plan.billingCadence);
680
762
  return /* @__PURE__ */ jsxs("div", {
681
763
  className: cn("relative rounded-lg border p-6 flex flex-col", isPopular && "border-primary border-2"),
@@ -721,12 +803,12 @@ const PricingCard = ({ plan, isPopular = false, isSubscribed = false }) => {
721
803
  }),
722
804
  /* @__PURE__ */ jsx("div", {
723
805
  className: "space-y-4 mb-6 grow",
724
- children: plan.phases.map((phase) => /* @__PURE__ */ jsx(PhaseSection, {
725
- phase,
806
+ children: /* @__PURE__ */ jsx(PlanEntitlements, {
807
+ phases: plan.phases,
726
808
  currency: plan.currency,
727
- showName: hasMultiplePhases,
728
- billingCadence: plan.billingCadence
729
- }, phase.key))
809
+ billingCadence: plan.billingCadence,
810
+ units: pricing?.units
811
+ })
730
812
  }),
731
813
  isSubscribed ? /* @__PURE__ */ jsx(Button, {
732
814
  variant: isPopular ? "default" : "secondary",
@@ -754,6 +836,7 @@ const PricingPage = () => {
754
836
  const deploymentName = useDeploymentName();
755
837
  const auth = useAuth();
756
838
  const { data: pricingTable } = usePlans();
839
+ const taxLegendSentence = taxBehaviorLegendSentence(collectDefaultTaxBehaviors(pricingTable.items[0]));
757
840
  const { data: subscriptions = { items: [] } } = useQuery({
758
841
  meta: { context: zudoku },
759
842
  queryKey: [`/v3/zudoku-metering/${deploymentName}/subscriptions`],
@@ -784,14 +867,24 @@ const PricingPage = () => {
784
867
  className: "text-sm mt-2",
785
868
  children: "Make sure your plans are set up and published."
786
869
  })]
787
- }) : /* @__PURE__ */ jsx("div", {
870
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
788
871
  className: "w-full grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(300px,max-content))] justify-center gap-6",
789
872
  children: pricingTable.items.map((plan) => /* @__PURE__ */ jsx(PricingCard, {
790
873
  plan,
791
874
  isPopular: plan.metadata?.zuplo_most_popular === "true",
792
875
  isSubscribed: subscriptions.items.some((subscription) => ["active", "canceled"].includes(subscription.status))
793
876
  }, plan.id))
794
- }),
877
+ }), taxLegendSentence && /* @__PURE__ */ jsxs("div", {
878
+ role: "note",
879
+ className: "mt-10 pt-6 border-t border-border max-w-2xl mx-auto text-center space-y-2",
880
+ children: [/* @__PURE__ */ jsx("p", {
881
+ className: "text-xs font-medium text-muted-foreground",
882
+ children: "Tax & Pricing"
883
+ }), /* @__PURE__ */ jsx("p", {
884
+ className: "text-xs text-muted-foreground",
885
+ children: taxLegendSentence
886
+ })]
887
+ })] }),
795
888
  /* @__PURE__ */ jsx(Slot.Target, { name: "pricing-page-after" })
796
889
  ]
797
890
  });
@@ -844,12 +937,6 @@ const SubscriptionChangeConfirmPage = () => {
844
937
  const taxAmount = getTaxAmountFromPurchaseDetails(purchaseDetails.data);
845
938
  const taxLabel = getTaxLabelFromPurchaseDetails(purchaseDetails.data);
846
939
  const taxInclusive = isTaxInclusiveFromPurchaseDetails(purchaseDetails.data);
847
- const rateCards = selectedPlan?.phases.at(-1)?.rateCards;
848
- const { quotas, features } = categorizeRateCards(rateCards ?? [], {
849
- currency: selectedPlan?.currency,
850
- units: pricing?.units,
851
- planBillingCadence: selectedPlan?.billingCadence
852
- });
853
940
  const price = selectedPlan ? getPriceFromPlan(selectedPlan) : null;
854
941
  const billingCycle = selectedPlan?.billingCadence ? formatDuration(selectedPlan.billingCadence) : null;
855
942
  const effectiveChangeMessage = mode === "downgrade" ? "This change will take effect at the start of your next billing cycle." : "This change will take effect immediately.";
@@ -951,15 +1038,11 @@ const SubscriptionChangeConfirmPage = () => {
951
1038
  className: "text-sm font-medium mb-3 mt-3",
952
1039
  children: "What's included:"
953
1040
  }),
954
- /* @__PURE__ */ jsxs("div", {
955
- className: "grid grid-cols-2 gap-2 text-muted-foreground",
956
- children: [quotas.map((quota) => /* @__PURE__ */ jsx(QuotaItem, {
957
- quota,
958
- className: "text-muted-foreground"
959
- }, quota.key)), features.map((feature) => /* @__PURE__ */ jsx(FeatureItem, {
960
- feature,
961
- className: "text-muted-foreground"
962
- }, feature.key))]
1041
+ /* @__PURE__ */ jsx(PlanEntitlements, {
1042
+ phases: selectedPlan.phases,
1043
+ currency: selectedPlan.currency,
1044
+ billingCadence: selectedPlan.billingCadence,
1045
+ units: pricing?.units
963
1046
  })
964
1047
  ] })]
965
1048
  }),
@@ -1011,6 +1094,17 @@ const useSubscriptions = (environmentName) => {
1011
1094
  });
1012
1095
  };
1013
1096
  //#endregion
1097
+ //#region src/utils/billables.ts
1098
+ const getActivePhase = (sub) => {
1099
+ const now = Date.now();
1100
+ return sub.phases.filter((p) => new Date(p.activeFrom).getTime() <= now && (!p.activeTo || new Date(p.activeTo).getTime() >= now)).sort((a, b) => new Date(b.activeFrom).getTime() - new Date(a.activeFrom).getTime())[0];
1101
+ };
1102
+ const activePhaseHasBillables = (sub) => getActivePhase(sub)?.items.some((i) => i.price != null) ?? false;
1103
+ const hasFutureBillables = (sub) => {
1104
+ const now = Date.now();
1105
+ return sub.phases.filter((p) => new Date(p.activeFrom).getTime() > now).some((p) => p.items.some((i) => i.price != null));
1106
+ };
1107
+ //#endregion
1014
1108
  //#region src/pages/subscriptions/ConfirmDeleteKeyAlert.tsx
1015
1109
  const ConfirmDeleteKeyAlert = ({ children, onDelete }) => {
1016
1110
  return /* @__PURE__ */ jsxs(AlertDialog, { children: [/* @__PURE__ */ jsx(AlertDialogTrigger, {
@@ -1303,19 +1397,22 @@ const ApiKeysList = ({ isPendingFirstPayment, apiKeys, deploymentName, consumerI
1303
1397
  };
1304
1398
  //#endregion
1305
1399
  //#region src/pages/subscriptions/CancelSubscriptionDialog.tsx
1306
- const CancelSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionId, billingPeriodEnd }) => {
1400
+ const CancelSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionId, billingPeriodEnd, hasCurrentBillables, hasFutureBillables }) => {
1307
1401
  const [confirmationText, setConfirmationText] = useState("");
1308
1402
  const isConfirmed = planName.startsWith(confirmationText);
1309
1403
  const deploymentName = useDeploymentName();
1310
1404
  const context = useZudoku();
1311
1405
  const queryClient = useQueryClient();
1406
+ const cancelTiming = hasCurrentBillables ? "next_billing_cycle" : "immediate";
1407
+ const isImmediateCancel = !hasCurrentBillables;
1408
+ const isTrialCancel = isImmediateCancel && hasFutureBillables;
1312
1409
  const cancelSubscriptionMutation = useMutation({
1313
1410
  mutationKey: [`/v3/zudoku-metering/${deploymentName}/subscriptions/${subscriptionId}/cancel`],
1314
1411
  meta: {
1315
1412
  context,
1316
1413
  request: {
1317
1414
  method: "POST",
1318
- body: JSON.stringify({ timing: "next_billing_cycle" })
1415
+ body: JSON.stringify({ timing: cancelTiming })
1319
1416
  }
1320
1417
  },
1321
1418
  onSuccess: async () => {
@@ -1335,28 +1432,32 @@ const CancelSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionId
1335
1432
  children: [
1336
1433
  /* @__PURE__ */ jsxs(Alert, {
1337
1434
  variant: "warning",
1338
- children: [
1339
- /* @__PURE__ */ jsx(CalendarIcon, { className: "size-4" }),
1340
- /* @__PURE__ */ jsx(AlertTitle, { children: "Your plan will be canceled at the end of your billing cycle." }),
1341
- /* @__PURE__ */ jsxs(AlertDescription, { children: [
1342
- "You'll retain access until ",
1343
- formatDate$2(billingPeriodEnd),
1344
- ". After your billing period ends, this plan will not renew and you would need to subscribe again to continue."
1345
- ] })
1346
- ]
1435
+ children: [/* @__PURE__ */ jsx(CalendarIcon, { className: "size-4" }), isTrialCancel ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(AlertTitle, { children: [
1436
+ "Cancel your trial of ",
1437
+ planName,
1438
+ "?"
1439
+ ] }), /* @__PURE__ */ jsxs(AlertDescription, { children: [
1440
+ "Your subscription will end now and you won't be charged when the trial would have converted to ",
1441
+ planName,
1442
+ "."
1443
+ ] })] }) : isImmediateCancel ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(AlertTitle, { children: [
1444
+ "Cancel your ",
1445
+ planName,
1446
+ " subscription?"
1447
+ ] }), /* @__PURE__ */ jsx(AlertDescription, { children: "Your subscription will end immediately. You'll lose access to its entitlements right away." })] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(AlertTitle, { children: "Your plan will be canceled at the end of your billing cycle." }), /* @__PURE__ */ jsxs(AlertDescription, { children: [
1448
+ "You'll retain access until ",
1449
+ formatDate$2(billingPeriodEnd),
1450
+ ". After your billing period ends, this plan will not renew and you would need to subscribe again to continue."
1451
+ ] })] })]
1347
1452
  }),
1348
1453
  /* @__PURE__ */ jsxs(Alert, {
1349
1454
  variant: "info",
1350
- children: [
1351
- /* @__PURE__ */ jsx(InfoIcon, { className: "size-4" }),
1352
- /* @__PURE__ */ jsx(AlertTitle, { children: "You can still resume before then" }),
1353
- /* @__PURE__ */ jsxs(AlertDescription, { children: [
1354
- "If you change your mind you have until",
1355
- " ",
1356
- formatDate$2(billingPeriodEnd),
1357
- " to remove this cancellation from Manage subscription."
1358
- ] })
1359
- ]
1455
+ children: [/* @__PURE__ */ jsx(InfoIcon, { className: "size-4" }), isImmediateCancel ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(AlertTitle, { children: "You can subscribe again at any time" }), /* @__PURE__ */ jsx(AlertDescription, { children: "After canceling, you can return to the pricing page and start a new subscription whenever you're ready." })] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(AlertTitle, { children: "You can still resume before then" }), /* @__PURE__ */ jsxs(AlertDescription, { children: [
1456
+ "If you change your mind you have until",
1457
+ " ",
1458
+ formatDate$2(billingPeriodEnd),
1459
+ " to remove this cancellation from Manage subscription."
1460
+ ] })] })]
1360
1461
  }),
1361
1462
  /* @__PURE__ */ jsxs("div", {
1362
1463
  className: "space-y-2",
@@ -1991,7 +2092,9 @@ const ManageSubscription = ({ subscription, planName }) => {
1991
2092
  onOpenChange: setCancelDialogOpen,
1992
2093
  planName,
1993
2094
  subscriptionId: subscription.id,
1994
- billingPeriodEnd
2095
+ billingPeriodEnd,
2096
+ hasCurrentBillables: activePhaseHasBillables(subscription),
2097
+ hasFutureBillables: hasFutureBillables(subscription)
1995
2098
  }),
1996
2099
  /* @__PURE__ */ jsx(RestoreSubscriptionDialog, {
1997
2100
  open: restoreDialogOpen,
@@ -2097,6 +2200,23 @@ const getOveragePriceFromItem = (item, currency, units) => {
2097
2200
  const unitLabel = units?.[item.key] ?? units?.[item.featureKey] ?? "unit";
2098
2201
  return `${formatPrice(parsed, currency)}/${unitLabel}`;
2099
2202
  };
2203
+ const getTierPricesFromItem = (item, currency, units) => {
2204
+ if (item.price?.type !== "tiered") return;
2205
+ const tiers = item.price.tiers;
2206
+ if (!tiers || tiers.length <= 1) return;
2207
+ const unitLabel = units?.[item.key] ?? units?.[item.featureKey] ?? "unit";
2208
+ return formatTieredPriceBreakdown({
2209
+ tiers: tiers.map((t) => ({
2210
+ upToAmount: t.upToAmount,
2211
+ unitPriceAmount: t.unitPrice?.amount,
2212
+ flatPriceAmount: t.flatPrice?.amount
2213
+ })),
2214
+ currency,
2215
+ unitLabel,
2216
+ includedLabel: "Included",
2217
+ omitIncludedUpToAmount: item.included?.entitlement?.issueAfterReset
2218
+ });
2219
+ };
2100
2220
  const getEntitlementsFromItems = (items, currency, units, fallbackBillingCadence) => {
2101
2221
  const features = [];
2102
2222
  for (const item of items) {
@@ -2110,7 +2230,8 @@ const getEntitlementsFromItems = (items, currency, units, fallbackBillingCadence
2110
2230
  name: item.name ?? item.featureKey ?? item.key,
2111
2231
  limit: entitlement.issueAfterReset,
2112
2232
  period: cadence ? formatDuration(cadence) : "month",
2113
- overagePrice: entitlement.isSoftLimit !== false ? getOveragePriceFromItem(item, currency, units) : void 0
2233
+ overagePrice: entitlement.isSoftLimit !== false ? getOveragePriceFromItem(item, currency, units) : void 0,
2234
+ tierPrices: getTierPricesFromItem(item, currency, units)
2114
2235
  });
2115
2236
  continue;
2116
2237
  }
@@ -2154,23 +2275,32 @@ const getEntitlementsFromItems = (items, currency, units, fallbackBillingCadence
2154
2275
  const getPhaseRows = (opts) => {
2155
2276
  const { subscription, currency, units } = opts;
2156
2277
  const phases = [...subscription.phases].sort((a, b) => new Date(a.activeFrom).getTime() - new Date(b.activeFrom).getTime());
2157
- const featureRows = [];
2278
+ const phaseGroups = [];
2158
2279
  for (const phase of phases) {
2159
2280
  const { features } = getEntitlementsFromItems(phase.items ?? [], currency, units, subscription.billingCadence);
2160
- for (const f of features) featureRows.push({
2281
+ const rows = [];
2282
+ for (const f of features) rows.push({
2161
2283
  key: f.key,
2162
2284
  name: f.name,
2163
2285
  entitlementType: f.entitlementType,
2164
2286
  limit: f.entitlementType === "metered" ? f.limit : void 0,
2165
2287
  period: f.entitlementType === "metered" ? f.period : void 0,
2166
2288
  overagePrice: f.entitlementType === "metered" ? f.overagePrice : void 0,
2289
+ tierPrices: f.entitlementType === "metered" ? f.tierPrices : void 0,
2167
2290
  value: f.entitlementType === "static" ? f.value : void 0,
2168
2291
  phaseId: phase.id,
2169
2292
  activeFrom: phase.activeFrom,
2170
2293
  activeTo: phase.activeTo
2171
2294
  });
2295
+ if (rows.length > 0) phaseGroups.push({
2296
+ id: phase.id,
2297
+ name: phase.name,
2298
+ activeFrom: phase.activeFrom,
2299
+ activeTo: phase.activeTo,
2300
+ rows
2301
+ });
2172
2302
  }
2173
- return { featureRows };
2303
+ return { phaseGroups };
2174
2304
  };
2175
2305
  const formatActiveRange = (activeFrom, activeTo) => {
2176
2306
  if (!activeTo) return `Starts ${formatDate$1(activeFrom)}`;
@@ -2181,6 +2311,7 @@ const SubscriptionPlanDetails = ({ subscription }) => {
2181
2311
  const plan = subscription.plan;
2182
2312
  const currency = subscription.currency ?? plan.currency;
2183
2313
  const priceInfo = getPriceFromPlan(plan);
2314
+ const taxLegendSentence = planHasDefaultTaxBehavior(plan) ? subscriptionTaxLegendSentence(plan.defaultTaxConfig?.behavior ?? "") : void 0;
2184
2315
  const primaryPrice = priceInfo.monthly === 0 && priceInfo.yearly === 0 ? /* @__PURE__ */ jsx("span", {
2185
2316
  className: "text-primary font-medium",
2186
2317
  children: "Free"
@@ -2191,7 +2322,7 @@ const SubscriptionPlanDetails = ({ subscription }) => {
2191
2322
  className: "text-muted-foreground",
2192
2323
  children: [" / ", formatDuration(plan.billingCadence)]
2193
2324
  })] });
2194
- const { featureRows } = getPhaseRows({
2325
+ const { phaseGroups } = getPhaseRows({
2195
2326
  subscription,
2196
2327
  currency,
2197
2328
  units: pricing?.units
@@ -2226,10 +2357,13 @@ const SubscriptionPlanDetails = ({ subscription }) => {
2226
2357
  /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2227
2358
  className: detailLabelClassName,
2228
2359
  children: "Price"
2229
- }), /* @__PURE__ */ jsx("dd", {
2360
+ }), /* @__PURE__ */ jsxs("dd", { children: [/* @__PURE__ */ jsx("div", {
2230
2361
  className: "flex flex-wrap items-baseline gap-1",
2231
2362
  children: primaryPrice
2232
- })] }),
2363
+ }), taxLegendSentence ? /* @__PURE__ */ jsx("p", {
2364
+ className: "text-xs text-muted-foreground mt-1",
2365
+ children: taxLegendSentence
2366
+ }) : null] })] }),
2233
2367
  /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2234
2368
  className: detailLabelClassName,
2235
2369
  children: "Current period"
@@ -2238,42 +2372,56 @@ const SubscriptionPlanDetails = ({ subscription }) => {
2238
2372
  children: subscription.alignment?.currentAlignedBillingPeriod ? formatDateRange(subscription.alignment.currentAlignedBillingPeriod.from, subscription.alignment.currentAlignedBillingPeriod.to) : "—"
2239
2373
  })] })
2240
2374
  ]
2241
- }), featureRows.length > 0 ? /* @__PURE__ */ jsx("div", {
2375
+ }), phaseGroups.length > 0 ? /* @__PURE__ */ jsx("div", {
2242
2376
  className: "space-y-5 pt-2 border-t border-border",
2243
2377
  children: /* @__PURE__ */ jsxs("div", {
2244
2378
  className: "space-y-2",
2245
2379
  children: [/* @__PURE__ */ jsx("p", {
2246
2380
  className: cn(sectionLabelClassName, "mb-5"),
2247
2381
  children: "Entitlements"
2248
- }), /* @__PURE__ */ jsx("ul", {
2249
- className: "space-y-3",
2250
- children: featureRows.map((row) => /* @__PURE__ */ jsxs("li", {
2251
- className: "grid gap-1 text-sm sm:grid-cols-4 sm:items-center sm:gap-4",
2252
- children: [
2253
- /* @__PURE__ */ jsx("div", {
2254
- className: "flex items-start gap-2 text-muted-foreground sm:col-span-2",
2255
- children: /* @__PURE__ */ jsxs("span", { children: [/* @__PURE__ */ jsxs("span", {
2256
- className: "text-foreground font-medium",
2257
- children: [row.name, " "]
2258
- }), row.entitlementType === "static" && row.value ? `: ${row.value}` : ""] })
2259
- }),
2260
- /* @__PURE__ */ jsx("div", {
2261
- className: "text-muted-foreground sm:text-right",
2262
- children: row.entitlementType === "metered" && row.limit != null ? /* @__PURE__ */ jsxs(Fragment, { children: [
2263
- formatNumber(row.limit),
2264
- row.period ? ` / ${row.period}` : "",
2265
- row.overagePrice ? /* @__PURE__ */ jsxs("div", {
2266
- className: "text-xs mt-0.5",
2267
- children: ["Overage: ", row.overagePrice]
2268
- }) : null
2269
- ] }) : row.entitlementType === "static" && row.value ? row.value : "Included"
2270
- }),
2271
- /* @__PURE__ */ jsx("div", {
2272
- className: "text-xs text-muted-foreground sm:text-right",
2273
- children: formatActiveRange(row.activeFrom, row.activeTo)
2274
- })
2275
- ]
2276
- }, `${row.key}:${row.phaseId}`))
2382
+ }), /* @__PURE__ */ jsx("div", {
2383
+ className: "space-y-5",
2384
+ children: phaseGroups.map((phase) => /* @__PURE__ */ jsxs("div", {
2385
+ className: "space-y-3",
2386
+ children: [phaseGroups.length > 1 ? /* @__PURE__ */ jsxs("div", {
2387
+ className: "text-sm font-medium text-card-foreground",
2388
+ children: [phase.name, /* @__PURE__ */ jsxs("span", {
2389
+ className: "text-muted-foreground font-normal",
2390
+ children: [
2391
+ " ",
2392
+ "",
2393
+ " ",
2394
+ formatActiveRange(phase.activeFrom, phase.activeTo)
2395
+ ]
2396
+ })]
2397
+ }) : null, /* @__PURE__ */ jsx("ul", {
2398
+ className: "space-y-3",
2399
+ children: phase.rows.map((row) => /* @__PURE__ */ jsx("li", {
2400
+ className: "text-sm",
2401
+ children: /* @__PURE__ */ jsxs("div", {
2402
+ className: "flex flex-col gap-1",
2403
+ children: [/* @__PURE__ */ jsxs("div", {
2404
+ className: "text-foreground font-medium",
2405
+ children: [row.name, row.entitlementType === "static" && row.value ? `: ${row.value}` : ""]
2406
+ }), /* @__PURE__ */ jsx("div", {
2407
+ className: "text-muted-foreground",
2408
+ children: row.entitlementType === "metered" && row.limit != null ? /* @__PURE__ */ jsxs(Fragment, { children: [
2409
+ formatNumber(row.limit),
2410
+ row.period ? ` / ${row.period}` : "",
2411
+ row.tierPrices && row.tierPrices.length > 0 ? /* @__PURE__ */ jsx("ul", {
2412
+ className: "text-xs mt-1 space-y-0.5",
2413
+ children: row.tierPrices.map((line) => /* @__PURE__ */ jsx("li", { children: line }, line))
2414
+ }) : null,
2415
+ row.overagePrice ? /* @__PURE__ */ jsxs("div", {
2416
+ className: "text-xs mt-0.5",
2417
+ children: ["Overage: ", row.overagePrice]
2418
+ }) : null
2419
+ ] }) : row.entitlementType === "static" && row.value ? null : "Included"
2420
+ })]
2421
+ })
2422
+ }, `${row.key}:${row.phaseId}`))
2423
+ })]
2424
+ }, phase.id))
2277
2425
  })]
2278
2426
  })
2279
2427
  }) : null]
@@ -2451,7 +2599,7 @@ const ActiveSubscription = ({ subscription, deploymentName }) => {
2451
2599
  meta: { context: zudoku }
2452
2600
  });
2453
2601
  const isPendingFirstPayment = usageQuery.data.paymentStatus.isFirstPayment === true && usageQuery.data.paymentStatus.status !== "paid" && usageQuery.data.paymentStatus.status !== "not_required";
2454
- const activePhase = subscription?.phases.find((p) => new Date(p.activeFrom) <= /* @__PURE__ */ new Date() && (!p.activeTo || new Date(p.activeTo) >= /* @__PURE__ */ new Date()));
2602
+ const activePhase = getActivePhase(subscription);
2455
2603
  return /* @__PURE__ */ jsxs(Fragment, { children: [
2456
2604
  planSwitched && /* @__PURE__ */ jsxs(DismissibleAlert, {
2457
2605
  variant: "info",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zuplo/zudoku-plugin-monetization",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zuplo/zudoku",