@zuplo/zudoku-plugin-monetization 0.0.43 → 0.0.45

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 +600 -184
  2. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { D as formatDurationAdjective, E as formatDuration, O as formatDurationInterval, T as formatPrice, _ as formatPlanPrice, a as planHasDefaultTaxBehavior, b as sameEntitlementSet, c as getPlanPriceSchedule, d as PlanEntitlements, f as PlanPhaseHeader, l as PlanPriceTag, o as subscriptionTaxLegendSentence, p as EntitlementList, r as isCustomPlan, t as PricingTable, u as PlanPriceSchedule, w as formatMinorCurrencyAmount, x as categorizeRateCards, y as comparePlanEntitlements } from "./PricingTable-WkG2n7V-.mjs";
2
2
  import { cn, createPlugin, joinUrl, throwIfProblemJson } from "zudoku";
3
- import { AlertTriangleIcon, ArrowDownIcon, ArrowLeftRightIcon, ArrowUpIcon, CalendarIcon, CheckCheckIcon, CheckIcon, CircleSlashIcon, ClockIcon, CreditCardIcon, Grid2x2XIcon, InfoIcon, Loader2Icon, LockIcon, MoreVerticalIcon, RefreshCcw, RefreshCwIcon, Settings, ShieldIcon, StarsIcon, Trash2Icon, XIcon } from "zudoku/icons";
3
+ import { AlertTriangleIcon, ArrowDownIcon, ArrowLeftRightIcon, ArrowUpIcon, BadgePercentIcon, CalendarIcon, CheckCheckIcon, CheckIcon, ChevronDownIcon, CircleSlashIcon, ClockIcon, CreditCardIcon, Grid2x2XIcon, HistoryIcon, InfoIcon, Loader2Icon, LockIcon, MoreVerticalIcon, RefreshCcw, RefreshCwIcon, Settings, ShieldIcon, StarsIcon, Trash2Icon, XIcon } from "zudoku/icons";
4
4
  import { Link, Outlet, useLocation, useNavigate, useSearchParams } from "zudoku/router";
5
5
  import { useAuth, useZudoku } from "zudoku/hooks";
6
6
  import { QueryClient, QueryClientProvider, queryOptions, useMutation, useQuery, useQueryClient, useSuspenseQuery } from "zudoku/react-query";
@@ -9,6 +9,7 @@ import { createContext, use, useEffect, useMemo, useState } from "react";
9
9
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
10
10
  import { Alert, AlertAction, AlertDescription, AlertTitle } from "zudoku/ui/Alert";
11
11
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "zudoku/ui/Card";
12
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "zudoku/ui/Collapsible";
12
13
  import { Separator } from "zudoku/ui/Separator";
13
14
  import { Button as Button$1 } from "zudoku/ui/Button";
14
15
  import { DismissibleAlert, DismissibleAlertAction } from "zudoku/ui/DismissibleAlert";
@@ -263,8 +264,13 @@ const formatBillingCycle = (duration) => {
263
264
  * The price is derived from the plan's rate cards via {@link formatPlanPrice}
264
265
  * and rendered in the plan's own billing cadence, so it stays correct for any
265
266
  * cadence (e.g. `$2.99/hour`).
267
+ *
268
+ * Pass `collapsibleDetails` to hide the "What's included" section behind a
269
+ * collapsed-by-default toggle — used on the plan-change confirmation page where
270
+ * the current and new plan are stacked and the user opens each to compare. Pass
271
+ * `label` to render a small eyebrow above the name (e.g. "Current plan").
266
272
  */
267
- const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxInclusive, units, entitlementsItemClassName }) => {
273
+ const PlanSummaryCard = ({ plan, label, descriptionFallback, taxAmount, taxLabel, taxInclusive, units, entitlementsItemClassName, collapsibleDetails = false }) => {
268
274
  const priceLabel = formatPlanPrice(plan);
269
275
  const schedule = getPlanPriceSchedule(plan);
270
276
  const billingCycle = plan.billingCadence ? formatDuration(plan.billingCadence) : null;
@@ -287,13 +293,20 @@ const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxIn
287
293
  children: plan.name.at(0)?.toUpperCase()
288
294
  }), /* @__PURE__ */ jsxs("div", {
289
295
  className: "flex flex-col",
290
- children: [/* @__PURE__ */ jsx("span", {
291
- className: "text-lg font-bold",
292
- children: plan.name
293
- }), /* @__PURE__ */ jsx("span", {
294
- className: "text-sm font-normal text-muted-foreground",
295
- children: plan.description || descriptionFallback
296
- })]
296
+ children: [
297
+ label && /* @__PURE__ */ jsx("span", {
298
+ className: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
299
+ children: label
300
+ }),
301
+ /* @__PURE__ */ jsx("span", {
302
+ className: "text-lg font-bold",
303
+ children: plan.name
304
+ }),
305
+ /* @__PURE__ */ jsx("span", {
306
+ className: "text-sm font-normal text-muted-foreground",
307
+ children: plan.description || descriptionFallback
308
+ })
309
+ ]
297
310
  })]
298
311
  }), !schedule && /* @__PURE__ */ jsxs("div", {
299
312
  className: "text-right",
@@ -314,20 +327,28 @@ const PlanSummaryCard = ({ plan, descriptionFallback, taxAmount, taxLabel, taxIn
314
327
  className: "text-right",
315
328
  children: [taxLine, billedLine]
316
329
  })]
317
- })] }), /* @__PURE__ */ jsxs(CardContent, { children: [
318
- /* @__PURE__ */ jsx(Separator, {}),
319
- /* @__PURE__ */ jsx("div", {
320
- className: "text-sm font-medium mb-3 mt-3",
321
- children: "What's included:"
322
- }),
323
- /* @__PURE__ */ jsx(PlanEntitlements, {
330
+ })] }), /* @__PURE__ */ jsxs(CardContent, { children: [/* @__PURE__ */ jsx(Separator, {}), collapsibleDetails ? /* @__PURE__ */ jsxs(Collapsible, { children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
331
+ className: "group flex w-full items-center justify-between text-sm font-medium mt-3",
332
+ children: ["Plan details", /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })]
333
+ }), /* @__PURE__ */ jsx(CollapsibleContent, {
334
+ className: "mt-3",
335
+ children: /* @__PURE__ */ jsx(PlanEntitlements, {
324
336
  phases: plan.phases,
325
337
  currency: plan.currency,
326
338
  billingCadence: plan.billingCadence,
327
339
  units,
328
340
  itemClassName: entitlementsItemClassName
329
341
  })
330
- ] })]
342
+ })] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
343
+ className: "text-sm font-medium mb-3 mt-3",
344
+ children: "What's included:"
345
+ }), /* @__PURE__ */ jsx(PlanEntitlements, {
346
+ phases: plan.phases,
347
+ currency: plan.currency,
348
+ billingCadence: plan.billingCadence,
349
+ units,
350
+ itemClassName: entitlementsItemClassName
351
+ })] })] })]
331
352
  });
332
353
  };
333
354
  //#endregion
@@ -802,76 +823,121 @@ const getSubscriptionPlanView = (subscription, options) => {
802
823
  };
803
824
  /** Whether a resolved view has any entitlements to render. */
804
825
  const hasSubscriptionEntitlements = (view) => view.usingItems ? view.entitlements.quotas.length > 0 || view.entitlements.features.length > 0 : view.fallbackPhases.some((p) => p.rateCards?.some((rc) => rc.entitlementTemplate));
805
- //#endregion
806
- //#region src/pages/components/SubscriptionEntitlements.tsx
826
+ const phaseStatus = (phase, now) => {
827
+ if (phase.activeTo && new Date(phase.activeTo).getTime() < now) return "past";
828
+ if (new Date(phase.activeFrom).getTime() > now) return "future";
829
+ return "current";
830
+ };
807
831
  /**
808
- * Render a subscription's entitlements from a resolved
809
- * {@link SubscriptionPlanView}: the active phase's *provisioned* items when
810
- * present (the real included quotas + per-unit prices), otherwise the catalog
811
- * plan's phases. Centralizes the "items, else fall back to plan phases" branch
812
- * shared by the subscription details page and the Switch Plan baseline.
813
- *
814
- * Renders `null` when there's nothing to show, so callers can wrap it with
815
- * {@link hasSubscriptionEntitlements} (for a section header / border) without
816
- * leaving an empty container behind.
832
+ * Rate cards for one subscription phase: its own provisioned items (the
833
+ * authoritative quotas/fees), falling back to the catalog plan phase with the
834
+ * same `key` when a phase (typically a future one) hasn't been provisioned yet.
817
835
  */
818
- const SubscriptionEntitlements = ({ view, currency, billingCadence, units, itemClassName }) => {
819
- if (view.usingItems) {
820
- const { quotas, features } = view.entitlements;
821
- return /* @__PURE__ */ jsx(EntitlementList, {
822
- quotas,
823
- features,
824
- itemClassName
825
- });
826
- }
827
- if (view.fallbackPhases.length === 0) return null;
828
- return /* @__PURE__ */ jsx(PlanEntitlements, {
829
- phases: view.fallbackPhases,
830
- currency,
831
- billingCadence,
832
- units,
833
- itemClassName
834
- });
836
+ const phaseRateCards = (plan, phase) => phase.items.length > 0 ? subscriptionItemsToRateCards(phase.items) : plan.phases?.find((p) => p.key === phase.key)?.rateCards ?? [];
837
+ const phasesByActiveFrom = (subscription) => [...subscription.phases].sort((a, b) => new Date(a.activeFrom).getTime() - new Date(b.activeFrom).getTime());
838
+ /**
839
+ * A clean ISO-8601 duration between two instants, preferring whole calendar
840
+ * units (years/months/weeks) over raw days so it formats the same way catalog
841
+ * plan durations do (e.g. `P1W` → "week", `P3M` → "3 months") rather than as
842
+ * "7 days" / "90 days". Returns `undefined` for a non-positive span.
843
+ */
844
+ const isoDurationBetween = (from, to) => {
845
+ const a = new Date(from);
846
+ const b = new Date(to);
847
+ const ms = b.getTime() - a.getTime();
848
+ if (!(ms > 0)) return void 0;
849
+ let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
850
+ const anchor = new Date(a);
851
+ anchor.setMonth(a.getMonth() + months);
852
+ if (anchor.getTime() > b.getTime()) months -= 1;
853
+ const exact = new Date(a);
854
+ exact.setMonth(a.getMonth() + months);
855
+ if (months >= 1 && exact.getTime() === b.getTime()) return months % 12 === 0 ? `P${months / 12}Y` : `P${months}M`;
856
+ const days = Math.round(ms / 864e5);
857
+ if (days >= 7 && days % 7 === 0) return `P${days / 7}W`;
858
+ return `P${days}D`;
835
859
  };
836
- //#endregion
837
- //#region src/pages/components/CurrentPlanBaseline.tsx
838
860
  /**
839
- * Baseline shown at the top of the Switch Plan modal: the current plan's price
840
- * and what it actually includes, so users have a concrete reference to compare
841
- * targets against. Both the price and the entitlements come from the
842
- * subscription's *provisioned* items (real included quotas + recurring fees),
843
- * falling back to the plan's rate cards only when items aren't present — see
844
- * {@link getSubscriptionPlanView}.
861
+ * The intended length of a subscription phase: the matching catalog plan
862
+ * phase's authored `duration` when available (the cleanest source), otherwise
863
+ * computed from the phase's own `activeFrom`/`activeTo`. `undefined` for an
864
+ * open-ended (final) phase the price schedule labels that one "After that".
845
865
  */
846
- const CurrentPlanBaseline = ({ subscription, units }) => {
866
+ const phaseDuration = (plan, phase) => {
867
+ const catalog = plan.phases?.find((p) => p.key === phase.key)?.duration;
868
+ if (catalog) return catalog;
869
+ if (!phase.activeTo) return void 0;
870
+ return isoDurationBetween(phase.activeFrom, phase.activeTo);
871
+ };
872
+ /**
873
+ * Resolve EVERY phase of a subscription (not just the active one) to a
874
+ * {@link SubscriptionPhaseView}, ordered by `activeFrom` and tagged
875
+ * past/current/future. Generalizes {@link getSubscriptionPlanView}: each phase's
876
+ * price and entitlements come from its own provisioned items (the authoritative
877
+ * source), falling back to the catalog plan phase with the same `key` when a
878
+ * phase (typically a future one) hasn't been provisioned yet. Powers the
879
+ * subscription details page's current + upcoming + previous phase timeline.
880
+ */
881
+ const getSubscriptionPhaseViews = (subscription, options) => {
847
882
  const plan = subscription.plan;
848
- const view = getSubscriptionPlanView(subscription, { units });
849
- return /* @__PURE__ */ jsxs("div", {
850
- className: "border rounded-lg p-4",
851
- children: [/* @__PURE__ */ jsxs("div", {
852
- className: "flex items-start justify-between gap-3 flex-wrap",
853
- children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
854
- className: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
855
- children: "Current Plan"
856
- }), /* @__PURE__ */ jsx("div", {
857
- className: "text-lg font-bold text-foreground",
858
- children: plan.name
859
- })] }), /* @__PURE__ */ jsx(PlanPriceTag, {
860
- label: view.priceLabel,
861
- currency: view.currency,
862
- billingCadence: view.billingCadence
863
- })]
864
- }), hasSubscriptionEntitlements(view) && /* @__PURE__ */ jsx("div", {
865
- className: "mt-3 pt-3 border-t",
866
- children: /* @__PURE__ */ jsx(SubscriptionEntitlements, {
867
- view,
868
- currency: view.currency,
869
- billingCadence: view.billingCadence,
870
- units
871
- })
872
- })]
883
+ const currency = subscription.currency ?? plan.currency;
884
+ const billingCadence = subscription.billingCadence ?? plan.billingCadence;
885
+ const now = Date.now();
886
+ return phasesByActiveFrom(subscription).map((phase) => {
887
+ const rateCards = phaseRateCards(plan, phase);
888
+ return {
889
+ id: phase.id,
890
+ name: phase.name,
891
+ status: phaseStatus(phase, now),
892
+ activeFrom: phase.activeFrom,
893
+ activeTo: phase.activeTo,
894
+ priceLabel: formatPlanPrice({
895
+ ...plan,
896
+ billingCadence,
897
+ phases: [{
898
+ key: phase.key,
899
+ name: phase.name,
900
+ rateCards
901
+ }]
902
+ }),
903
+ entitlements: categorizeRateCards(rateCards, {
904
+ currency,
905
+ units: options?.units,
906
+ planBillingCadence: billingCadence
907
+ }),
908
+ billingCadence,
909
+ currency
910
+ };
873
911
  });
874
912
  };
913
+ /**
914
+ * Build a catalog-shaped {@link Plan} from a subscription's *own* current and
915
+ * future phases (past phases dropped), so the plan-change confirmation page can
916
+ * render the current subscription with the exact same pricing-table card
917
+ * ({@link formatPlanPrice} / `getPlanPriceSchedule` / `PlanEntitlements`) used
918
+ * for the new plan. Each phase's rate cards come from its provisioned items
919
+ * (the authoritative source) — the embedded `subscription.plan` snapshot is
920
+ * unreliable here (it can arrive with `phases: []`), so we never read its
921
+ * phases for pricing. Each phase's `duration` is carried so the price schedule
922
+ * labels read "First 3 months" / "After that" exactly like the new plan, rather
923
+ * than falling back to the phase name (see {@link phaseDuration}).
924
+ */
925
+ const subscriptionToCurrentPlan = (subscription) => {
926
+ const plan = subscription.plan;
927
+ const now = Date.now();
928
+ const phases = phasesByActiveFrom(subscription).filter((p) => !(p.activeTo && new Date(p.activeTo).getTime() < now)).map((phase) => ({
929
+ key: phase.key,
930
+ name: phase.name,
931
+ duration: phaseDuration(plan, phase),
932
+ rateCards: phaseRateCards(plan, phase)
933
+ }));
934
+ return {
935
+ ...plan,
936
+ currency: subscription.currency ?? plan.currency,
937
+ billingCadence: subscription.billingCadence ?? plan.billingCadence,
938
+ phases
939
+ };
940
+ };
875
941
  //#endregion
876
942
  //#region src/pages/SubscriptionChangeConfirmPage.tsx
877
943
  const SubscriptionChangeConfirmPage = () => {
@@ -911,20 +977,27 @@ const SubscriptionChangeConfirmPage = () => {
911
977
  children: /* @__PURE__ */ jsxs("div", {
912
978
  className: "space-y-3",
913
979
  children: [
914
- currentSubscription && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(CurrentPlanBaseline, {
915
- subscription: currentSubscription,
916
- units: pricing?.units
980
+ currentSubscription && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(PlanSummaryCard, {
981
+ plan: subscriptionToCurrentPlan(currentSubscription),
982
+ label: "Current plan",
983
+ descriptionFallback: "",
984
+ taxLabel: "",
985
+ taxInclusive: false,
986
+ units: pricing?.units,
987
+ collapsibleDetails: true
917
988
  }), /* @__PURE__ */ jsxs("div", {
918
989
  className: "flex items-center justify-center gap-1.5 text-sm text-muted-foreground",
919
990
  children: [/* @__PURE__ */ jsx(ArrowDownIcon, { className: "size-4" }), " Changing to"]
920
991
  })] }),
921
992
  selectedPlan && /* @__PURE__ */ jsx(PlanSummaryCard, {
922
993
  plan: selectedPlan,
923
- descriptionFallback: "New plan",
994
+ label: "New plan",
995
+ descriptionFallback: "",
924
996
  taxAmount,
925
997
  taxLabel,
926
998
  taxInclusive,
927
- units: pricing?.units
999
+ units: pricing?.units,
1000
+ collapsibleDetails: true
928
1001
  }),
929
1002
  /* @__PURE__ */ jsxs("div", {
930
1003
  className: "rounded-lg bg-muted/50 p-3 text-sm space-y-1",
@@ -961,6 +1034,24 @@ const useSubscriptions = () => {
961
1034
  });
962
1035
  };
963
1036
  //#endregion
1037
+ //#region src/hooks/usePendingCredits.ts
1038
+ /**
1039
+ * Fetch the operator-applied usage credits for a subscription, via the Zuplo
1040
+ * metering `.../pending-credits` endpoint. Failures are swallowed (`retry: false`,
1041
+ * `throwOnError: false`) so the usage page never breaks when credits are
1042
+ * unavailable — the credit banner simply isn't shown.
1043
+ */
1044
+ const usePendingCredits = (deploymentName, subscriptionId) => {
1045
+ const zudoku = useZudoku();
1046
+ return useQuery({
1047
+ queryKey: [`/v3/zudoku-metering/${deploymentName}/subscriptions/${subscriptionId}/pending-credits`],
1048
+ meta: { context: zudoku },
1049
+ refetchOnWindowFocus: true,
1050
+ retry: false,
1051
+ throwOnError: false
1052
+ });
1053
+ };
1054
+ //#endregion
964
1055
  //#region src/pages/subscriptions/ConfirmDeleteKeyAlert.tsx
965
1056
  const ConfirmDeleteKeyAlert = ({ children, onDelete }) => {
966
1057
  return /* @__PURE__ */ jsxs(AlertDialog, { children: [/* @__PURE__ */ jsx(AlertDialogTrigger, {
@@ -1446,6 +1537,76 @@ const RestoreSubscriptionDialog = ({ open, onOpenChange, planName, subscriptionI
1446
1537
  });
1447
1538
  };
1448
1539
  //#endregion
1540
+ //#region src/pages/components/SubscriptionEntitlements.tsx
1541
+ /**
1542
+ * Render a subscription's entitlements from a resolved
1543
+ * {@link SubscriptionPlanView}: the active phase's *provisioned* items when
1544
+ * present (the real included quotas + per-unit prices), otherwise the catalog
1545
+ * plan's phases. Centralizes the "items, else fall back to plan phases" branch
1546
+ * shared by the subscription details page and the Switch Plan baseline.
1547
+ *
1548
+ * Renders `null` when there's nothing to show, so callers can wrap it with
1549
+ * {@link hasSubscriptionEntitlements} (for a section header / border) without
1550
+ * leaving an empty container behind.
1551
+ */
1552
+ const SubscriptionEntitlements = ({ view, currency, billingCadence, units, itemClassName }) => {
1553
+ if (view.usingItems) {
1554
+ const { quotas, features } = view.entitlements;
1555
+ return /* @__PURE__ */ jsx(EntitlementList, {
1556
+ quotas,
1557
+ features,
1558
+ itemClassName
1559
+ });
1560
+ }
1561
+ if (view.fallbackPhases.length === 0) return null;
1562
+ return /* @__PURE__ */ jsx(PlanEntitlements, {
1563
+ phases: view.fallbackPhases,
1564
+ currency,
1565
+ billingCadence,
1566
+ units,
1567
+ itemClassName
1568
+ });
1569
+ };
1570
+ //#endregion
1571
+ //#region src/pages/components/CurrentPlanBaseline.tsx
1572
+ /**
1573
+ * Baseline shown at the top of the Switch Plan modal: the current plan's price
1574
+ * and what it actually includes, so users have a concrete reference to compare
1575
+ * targets against. Both the price and the entitlements come from the
1576
+ * subscription's *provisioned* items (real included quotas + recurring fees),
1577
+ * falling back to the plan's rate cards only when items aren't present — see
1578
+ * {@link getSubscriptionPlanView}.
1579
+ */
1580
+ const CurrentPlanBaseline = ({ subscription, units }) => {
1581
+ const plan = subscription.plan;
1582
+ const view = getSubscriptionPlanView(subscription, { units });
1583
+ return /* @__PURE__ */ jsxs("div", {
1584
+ className: "border rounded-lg p-4",
1585
+ children: [/* @__PURE__ */ jsxs("div", {
1586
+ className: "flex items-start justify-between gap-3 flex-wrap",
1587
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", {
1588
+ className: "text-xs font-medium text-muted-foreground uppercase tracking-wide",
1589
+ children: "Current Plan"
1590
+ }), /* @__PURE__ */ jsx("div", {
1591
+ className: "text-lg font-bold text-foreground",
1592
+ children: plan.name
1593
+ })] }), /* @__PURE__ */ jsx(PlanPriceTag, {
1594
+ label: view.priceLabel,
1595
+ currency: view.currency,
1596
+ billingCadence: view.billingCadence
1597
+ })]
1598
+ }), hasSubscriptionEntitlements(view) && /* @__PURE__ */ jsx("div", {
1599
+ className: "mt-3 pt-3 border-t",
1600
+ children: /* @__PURE__ */ jsx(SubscriptionEntitlements, {
1601
+ view,
1602
+ currency: view.currency,
1603
+ billingCadence: view.billingCadence,
1604
+ units
1605
+ })
1606
+ })]
1607
+ });
1608
+ };
1609
+ //#endregion
1449
1610
  //#region src/pages/components/PlanChangeCard.tsx
1450
1611
  const MODE_LABEL = {
1451
1612
  upgrade: "Upgrade",
@@ -1924,17 +2085,90 @@ const ManageSubscription = ({ subscription, planName }) => {
1924
2085
  ] });
1925
2086
  };
1926
2087
  //#endregion
2088
+ //#region src/pages/components/SubscriptionPhases.tsx
2089
+ const phaseDateLabel = (view) => {
2090
+ if (view.status === "current") return "Current phase";
2091
+ if (view.status === "future") return `Starts ${formatDateTime(view.activeFrom)}`;
2092
+ return view.activeTo ? `${formatDateTime(view.activeFrom)} – ${formatDateTime(view.activeTo)}` : formatDateTime(view.activeFrom);
2093
+ };
2094
+ /**
2095
+ * One phase of a subscription: a header with the phase name, its timing
2096
+ * sub-label, and the phase's own price, followed by that phase's included
2097
+ * quotas/features. The header always renders (even when the phase has no
2098
+ * entitlements) so the price/timing is never hidden.
2099
+ */
2100
+ const SubscriptionPhaseSection = ({ view }) => {
2101
+ const dateLabel = phaseDateLabel(view);
2102
+ return /* @__PURE__ */ jsxs("div", {
2103
+ className: "space-y-2",
2104
+ children: [/* @__PURE__ */ jsxs("div", {
2105
+ className: "flex items-baseline justify-between gap-3",
2106
+ children: [/* @__PURE__ */ jsxs("div", {
2107
+ className: "text-sm font-medium text-card-foreground",
2108
+ children: [view.name, dateLabel && /* @__PURE__ */ jsxs("span", {
2109
+ className: "text-muted-foreground font-normal",
2110
+ children: [
2111
+ " ",
2112
+ "· ",
2113
+ dateLabel
2114
+ ]
2115
+ })]
2116
+ }), /* @__PURE__ */ jsx(PlanPriceTag, {
2117
+ label: view.priceLabel,
2118
+ currency: view.currency,
2119
+ billingCadence: view.billingCadence
2120
+ })]
2121
+ }), /* @__PURE__ */ jsx(EntitlementList, {
2122
+ quotas: view.entitlements.quotas,
2123
+ features: view.entitlements.features
2124
+ })]
2125
+ });
2126
+ };
2127
+ /**
2128
+ * The subscription's future phases (each with its start date, price, and
2129
+ * entitlements), shown after the current phase on the details page and on the
2130
+ * Switch Plan baseline. Renders nothing when there are no upcoming phases.
2131
+ */
2132
+ const UpcomingPhases = ({ subscription, units }) => {
2133
+ const future = getSubscriptionPhaseViews(subscription, { units }).filter((v) => v.status === "future");
2134
+ if (future.length === 0) return null;
2135
+ return /* @__PURE__ */ jsx("div", {
2136
+ className: "space-y-4",
2137
+ children: future.map((view) => /* @__PURE__ */ jsx(SubscriptionPhaseSection, { view }, view.id))
2138
+ });
2139
+ };
2140
+ /**
2141
+ * The subscription's already-ended phases, collapsed by default, each labelled
2142
+ * with the exact dates it ran. Lets users review what they previously paid for
2143
+ * without cluttering the page. Renders nothing when there are no past phases.
2144
+ */
2145
+ const PreviousPhases = ({ subscription, units }) => {
2146
+ const past = getSubscriptionPhaseViews(subscription, { units }).filter((v) => v.status === "past");
2147
+ if (past.length === 0) return null;
2148
+ return /* @__PURE__ */ jsxs(Collapsible, { children: [/* @__PURE__ */ jsxs(CollapsibleTrigger, {
2149
+ className: "group flex w-full items-center justify-between text-sm font-medium",
2150
+ children: ["Previous phases", /* @__PURE__ */ jsx(ChevronDownIcon, { className: "size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" })]
2151
+ }), /* @__PURE__ */ jsx(CollapsibleContent, {
2152
+ className: "mt-3 space-y-4",
2153
+ children: past.map((view) => /* @__PURE__ */ jsx(SubscriptionPhaseSection, { view }, view.id))
2154
+ })] });
2155
+ };
2156
+ //#endregion
1927
2157
  //#region src/pages/subscriptions/SubscriptionPlanDetails.tsx
1928
2158
  const detailLabelClassName = "text-sm font-semibold tracking-wide mb-1";
1929
2159
  const sectionLabelClassName = "text-base font-semibold tracking-wide mb-3 mt-2";
1930
2160
  const formatDateTimeRange = (from, to) => `${formatDateTime(from)} – ${formatDateTime(to)}`;
1931
2161
  const SubscriptionPlanDetails = ({ subscription }) => {
1932
2162
  const { pricing } = useMonetizationConfig();
2163
+ const hasEnded = !!subscription.activeTo && new Date(subscription.activeTo).getTime() < Date.now();
1933
2164
  const plan = subscription.plan;
1934
2165
  const view = getSubscriptionPlanView(subscription, { units: pricing?.units });
1935
2166
  const { priceLabel } = view;
1936
2167
  const taxLegendSentence = planHasDefaultTaxBehavior(plan) ? subscriptionTaxLegendSentence(plan.defaultTaxConfig?.behavior ?? "") : void 0;
1937
2168
  const hasEntitlements = hasSubscriptionEntitlements(view);
2169
+ const phaseViews = getSubscriptionPhaseViews(subscription, { units: pricing?.units });
2170
+ const hasUpcomingPhases = phaseViews.some((p) => p.status === "future");
2171
+ const hasPreviousPhases = phaseViews.some((p) => p.status === "past");
1938
2172
  return /* @__PURE__ */ jsxs("div", {
1939
2173
  className: "space-y-4",
1940
2174
  children: [/* @__PURE__ */ jsx(Heading, {
@@ -1945,104 +2179,261 @@ const SubscriptionPlanDetails = ({ subscription }) => {
1945
2179
  children: plan.name
1946
2180
  }), plan.description ? /* @__PURE__ */ jsx(CardDescription, { children: plan.description }) : null] }), /* @__PURE__ */ jsxs(CardContent, {
1947
2181
  className: "space-y-6",
1948
- children: [/* @__PURE__ */ jsxs("dl", {
1949
- className: "grid gap-4 sm:grid-cols-2 text-sm",
1950
- children: [
1951
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
1952
- className: detailLabelClassName,
1953
- children: "Subscription ID"
1954
- }), /* @__PURE__ */ jsx("dd", {
1955
- className: "text-foreground font-mono text-xs break-all",
1956
- children: subscription.id
1957
- })] }),
1958
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
1959
- className: detailLabelClassName,
1960
- children: "Active since"
1961
- }), /* @__PURE__ */ jsx("dd", {
1962
- className: "text-foreground",
1963
- children: formatDateTime(subscription.activeFrom)
1964
- })] }),
1965
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
1966
- className: detailLabelClassName,
1967
- children: "Price"
1968
- }), /* @__PURE__ */ jsxs("dd", { children: [/* @__PURE__ */ jsx("div", {
1969
- className: "flex flex-wrap items-baseline gap-1",
1970
- children: /* @__PURE__ */ jsx(PlanPriceTag, {
1971
- label: priceLabel,
1972
- currency: view.currency,
1973
- billingCadence: view.billingCadence,
1974
- description: true
1975
- })
1976
- }), taxLegendSentence ? /* @__PURE__ */ jsx("p", {
1977
- className: "text-xs text-muted-foreground mt-1",
1978
- children: taxLegendSentence
1979
- }) : null] })] }),
1980
- /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
1981
- className: detailLabelClassName,
1982
- children: "Current period"
1983
- }), /* @__PURE__ */ jsx("dd", {
1984
- className: "text-foreground",
1985
- children: subscription.alignment?.currentAlignedBillingPeriod ? formatDateTimeRange(subscription.alignment.currentAlignedBillingPeriod.from, subscription.alignment.currentAlignedBillingPeriod.to) : ""
1986
- })] })
1987
- ]
1988
- }), hasEntitlements ? /* @__PURE__ */ jsxs("div", {
1989
- className: "space-y-2 pt-2 border-t border-border",
1990
- children: [/* @__PURE__ */ jsx("p", {
1991
- className: sectionLabelClassName,
1992
- children: "What's included"
1993
- }), /* @__PURE__ */ jsx(SubscriptionEntitlements, {
1994
- view,
1995
- currency: view.currency,
1996
- billingCadence: view.billingCadence,
1997
- units: pricing?.units
1998
- })]
1999
- }) : null]
2182
+ children: [
2183
+ /* @__PURE__ */ jsxs("dl", {
2184
+ className: "grid gap-4 sm:grid-cols-2 text-sm",
2185
+ children: [
2186
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2187
+ className: detailLabelClassName,
2188
+ children: "Subscription ID"
2189
+ }), /* @__PURE__ */ jsx("dd", {
2190
+ className: "text-foreground font-mono text-xs break-all",
2191
+ children: subscription.id
2192
+ })] }),
2193
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2194
+ className: detailLabelClassName,
2195
+ children: "Active since"
2196
+ }), /* @__PURE__ */ jsx("dd", {
2197
+ className: "text-foreground",
2198
+ children: formatDateTime(subscription.activeFrom)
2199
+ })] }),
2200
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2201
+ className: detailLabelClassName,
2202
+ children: "Price"
2203
+ }), /* @__PURE__ */ jsxs("dd", { children: [/* @__PURE__ */ jsx("div", {
2204
+ className: "flex flex-wrap items-baseline gap-1",
2205
+ children: /* @__PURE__ */ jsx(PlanPriceTag, {
2206
+ label: priceLabel,
2207
+ currency: view.currency,
2208
+ billingCadence: view.billingCadence,
2209
+ description: true
2210
+ })
2211
+ }), taxLegendSentence ? /* @__PURE__ */ jsx("p", {
2212
+ className: "text-xs text-muted-foreground mt-1",
2213
+ children: taxLegendSentence
2214
+ }) : null] })] }),
2215
+ /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", {
2216
+ className: detailLabelClassName,
2217
+ children: hasEnded ? "Ended" : "Current period"
2218
+ }), /* @__PURE__ */ jsx("dd", {
2219
+ className: "text-foreground",
2220
+ children: hasEnded ? formatDateTime(subscription.activeTo ?? "") : subscription.alignment?.currentAlignedBillingPeriod ? formatDateTimeRange(subscription.alignment.currentAlignedBillingPeriod.from, subscription.alignment.currentAlignedBillingPeriod.to) : "—"
2221
+ })] })
2222
+ ]
2223
+ }),
2224
+ hasEntitlements ? /* @__PURE__ */ jsxs("div", {
2225
+ className: "space-y-2 pt-2 border-t border-border",
2226
+ children: [/* @__PURE__ */ jsx("p", {
2227
+ className: sectionLabelClassName,
2228
+ children: "What's included"
2229
+ }), /* @__PURE__ */ jsx(SubscriptionEntitlements, {
2230
+ view,
2231
+ currency: view.currency,
2232
+ billingCadence: view.billingCadence,
2233
+ units: pricing?.units
2234
+ })]
2235
+ }) : null,
2236
+ hasUpcomingPhases ? /* @__PURE__ */ jsxs("div", {
2237
+ className: "space-y-3 pt-2 border-t border-border",
2238
+ children: [/* @__PURE__ */ jsx("p", {
2239
+ className: sectionLabelClassName,
2240
+ children: "Upcoming phases"
2241
+ }), /* @__PURE__ */ jsx(UpcomingPhases, {
2242
+ subscription,
2243
+ units: pricing?.units
2244
+ })]
2245
+ }) : null,
2246
+ hasPreviousPhases ? /* @__PURE__ */ jsx("div", {
2247
+ className: "pt-2 border-t border-border",
2248
+ children: /* @__PURE__ */ jsx(PreviousPhases, {
2249
+ subscription,
2250
+ units: pricing?.units
2251
+ })
2252
+ }) : null
2253
+ ]
2000
2254
  })] })]
2001
2255
  });
2002
2256
  };
2003
2257
  //#endregion
2258
+ //#region src/utils/priceIncludedUnits.ts
2259
+ const partAmount = (part) => {
2260
+ if (!part || part.amount === void 0) return 0;
2261
+ const amount = parseFloat(part.amount);
2262
+ return Number.isFinite(amount) ? amount : Number.POSITIVE_INFINITY;
2263
+ };
2264
+ const priceIncludedUnits = (price) => {
2265
+ if (!price) return 0;
2266
+ if (price.type === "flat" || price.type === "package" || price.type === "dynamic") return 0;
2267
+ if (price.tiers?.length) {
2268
+ if ((price.mode ?? "graduated") !== "graduated") return 0;
2269
+ const sorted = [...price.tiers].sort((a, b) => {
2270
+ if (a.upToAmount === void 0) return 1;
2271
+ if (b.upToAmount === void 0) return -1;
2272
+ return Number(a.upToAmount) - Number(b.upToAmount);
2273
+ });
2274
+ let free = 0;
2275
+ for (const [index, tier] of sorted.entries()) {
2276
+ if (partAmount(tier.unitPrice) > 0) break;
2277
+ if (index > 0 && partAmount(tier.flatPrice) > 0) break;
2278
+ if (tier.upToAmount === void 0) return Number.POSITIVE_INFINITY;
2279
+ const bound = Number(tier.upToAmount);
2280
+ if (!Number.isFinite(bound) || bound <= free) break;
2281
+ free = bound;
2282
+ }
2283
+ return free;
2284
+ }
2285
+ if (price.type === "unit") {
2286
+ const amount = parseFloat(price.amount ?? "");
2287
+ return Number.isFinite(amount) && amount === 0 ? Number.POSITIVE_INFINITY : 0;
2288
+ }
2289
+ return 0;
2290
+ };
2291
+ //#endregion
2292
+ //#region src/pages/subscriptions/deriveUsageView.ts
2293
+ const formatAmount = (amount) => {
2294
+ const value = parseFloat(amount ?? "");
2295
+ return Number.isFinite(value) ? `$${value.toFixed(2)}` : void 0;
2296
+ };
2297
+ const pluralizeUnit = (unitName) => unitName.endsWith("s") ? unitName : `${unitName}s`;
2298
+ /** The label for what additional usage costs, when the shape has one number. */
2299
+ const rateLabelFor = (price, unitName) => {
2300
+ if (!price) return void 0;
2301
+ if (price.type === "unit") {
2302
+ const amount = formatAmount(price.amount);
2303
+ return amount ? `${amount}/${unitName}` : void 0;
2304
+ }
2305
+ if (price.type === "tiered") {
2306
+ const amount = formatAmount((price.tiers?.find((t) => !t.upToAmount) ?? price.tiers?.at(-1))?.unitPrice?.amount);
2307
+ return amount ? `${amount}/${unitName}` : void 0;
2308
+ }
2309
+ if (price.type === "package") {
2310
+ const amount = formatAmount(price.amount);
2311
+ if (!amount) return void 0;
2312
+ const size = parseFloat(price.quantityPerUnit ?? "");
2313
+ return Number.isFinite(size) && size > 0 ? `${amount} per ${size.toLocaleString()} ${pluralizeUnit(unitName)}` : amount;
2314
+ }
2315
+ };
2316
+ const NO_CAP = "There is no usage cap.";
2317
+ const deriveUsageView = (meter, item, unitName = "unit") => {
2318
+ const quota = meter.balance + meter.usage - meter.overage;
2319
+ const isSoftLimit = item?.included?.entitlement?.isSoftLimit ?? true;
2320
+ const rateLabel = rateLabelFor(item?.price, unitName);
2321
+ if (!isSoftLimit) return {
2322
+ kind: "capped",
2323
+ usage: meter.usage,
2324
+ quota,
2325
+ remaining: meter.balance,
2326
+ atLimit: meter.usage >= quota,
2327
+ rateLabel
2328
+ };
2329
+ if (!item) return {
2330
+ kind: "meteredGeneric",
2331
+ usage: meter.usage,
2332
+ quota: quota > 0 ? quota : void 0,
2333
+ caption: "Usage is billed per your plan's pricing."
2334
+ };
2335
+ if (!item.price || item.price.type === "flat") return {
2336
+ kind: "meteredGeneric",
2337
+ usage: meter.usage,
2338
+ quota: quota > 0 ? quota : void 0,
2339
+ caption: `Usage doesn't change your bill. ${NO_CAP}`
2340
+ };
2341
+ const isGraduated = item.price.type === "tiered" && (item.price.mode ?? "graduated") === "graduated";
2342
+ const derivable = item.price.type === "unit" || isGraduated;
2343
+ const freeUnits = priceIncludedUnits(item.price);
2344
+ if (derivable) {
2345
+ if (freeUnits === 0) return {
2346
+ kind: "payAsYouGo",
2347
+ usage: meter.usage,
2348
+ caption: `Pay as you go — every ${unitName} is billed; there is no usage cap.`,
2349
+ rateLabel
2350
+ };
2351
+ if (freeUnits === Number.POSITIVE_INFINITY) return {
2352
+ kind: "meteredGeneric",
2353
+ usage: meter.usage,
2354
+ quota: quota > 0 ? quota : void 0,
2355
+ caption: `Included with your plan. ${NO_CAP}`,
2356
+ rateLabel
2357
+ };
2358
+ if (quota > 0) return {
2359
+ kind: "included",
2360
+ usage: meter.usage,
2361
+ included: quota,
2362
+ remaining: meter.balance,
2363
+ overage: meter.overage,
2364
+ rateLabel
2365
+ };
2366
+ return {
2367
+ kind: "meteredGeneric",
2368
+ usage: meter.usage,
2369
+ caption: `The first ${freeUnits.toLocaleString()} ${pluralizeUnit(unitName)} are included; additional usage is billed. ${NO_CAP}`,
2370
+ rateLabel
2371
+ };
2372
+ }
2373
+ return {
2374
+ kind: "meteredGeneric",
2375
+ usage: meter.usage,
2376
+ quota: quota > 0 ? quota : void 0,
2377
+ caption: `Usage is billed per your plan's pricing. ${NO_CAP}`,
2378
+ rateLabel
2379
+ };
2380
+ };
2381
+ //#endregion
2004
2382
  //#region src/pages/subscriptions/Usage.tsx
2005
2383
  const isMeteredEntitlement = (entitlement) => {
2006
2384
  return "balance" in entitlement;
2007
2385
  };
2008
- const UsageItem = ({ meter, item, subscription, featureKey }) => {
2386
+ const UsageItem = ({ meter, item, subscription, featureKey, pendingCredit }) => {
2387
+ const { pricing } = useMonetizationConfig();
2009
2388
  const cadence = item?.billingCadence ?? subscription?.billingCadence;
2010
2389
  const billingPeriod = cadence ? formatDurationAdjective(cadence) : "monthly";
2011
- const isSoftLimit = item?.included?.entitlement?.isSoftLimit ?? true;
2012
- const rate = (item?.price?.tiers?.find((t) => !t.upToAmount) ?? item?.price?.tiers?.at(-1))?.unitPrice?.amount;
2013
- const hasOverage = meter.overage > 0;
2014
- const limit = meter.balance + meter.usage - meter.overage;
2015
- const isAtLimit = !isSoftLimit && meter.usage >= limit;
2016
- const dangerZone = hasOverage || isAtLimit;
2390
+ const view = deriveUsageView(meter, item, pricing?.units?.[item?.key ?? ""] ?? pricing?.units?.[featureKey] ?? "unit");
2391
+ const atHardLimit = view.kind === "capped" && view.atLimit;
2392
+ const overIncluded = view.kind === "included" && view.overage > 0;
2393
+ const upgradeAction = (variant) => subscription && /* @__PURE__ */ jsx(AlertAction, { children: /* @__PURE__ */ jsx(SwitchPlanModal, {
2394
+ subscription,
2395
+ children: /* @__PURE__ */ jsxs(Button, {
2396
+ variant,
2397
+ size: "xs",
2398
+ children: [/* @__PURE__ */ jsx(ArrowUpIcon, {}), "Upgrade"]
2399
+ })
2400
+ }) });
2017
2401
  return /* @__PURE__ */ jsxs(Card, {
2018
- className: cn(dangerZone && "border-destructive bg-destructive/5"),
2402
+ className: cn(atHardLimit && "border-destructive bg-destructive/5"),
2019
2403
  children: [/* @__PURE__ */ jsxs(CardHeader, { children: [
2020
- hasOverage && isSoftLimit && /* @__PURE__ */ jsxs(Alert, {
2021
- variant: "destructive",
2404
+ pendingCredit && /* @__PURE__ */ jsxs(Alert, {
2022
2405
  className: "mb-4",
2023
2406
  children: [
2024
- /* @__PURE__ */ jsx(AlertTriangleIcon, { className: "size-4 text-red-600 shrink-0" }),
2407
+ /* @__PURE__ */ jsx(BadgePercentIcon, { className: "size-4 text-green-600 shrink-0" }),
2408
+ /* @__PURE__ */ jsx(AlertTitle, { children: "Usage credit applied" }),
2409
+ /* @__PURE__ */ jsxs(AlertDescription, { children: [
2410
+ "A credit of ",
2411
+ pendingCredit.units.toLocaleString(),
2412
+ " ",
2413
+ pendingCredit.units === 1 ? "unit" : "units",
2414
+ " applies to this billing period and will be deducted from your next invoice automatically."
2415
+ ] })
2416
+ ]
2417
+ }),
2418
+ overIncluded && /* @__PURE__ */ jsxs(Alert, {
2419
+ variant: "warning",
2420
+ className: "mb-4",
2421
+ children: [
2422
+ /* @__PURE__ */ jsx(AlertTriangleIcon, { className: "size-4 shrink-0" }),
2025
2423
  /* @__PURE__ */ jsxs(AlertTitle, { children: [
2026
- "You've exceeded your ",
2424
+ "You've used your included ",
2027
2425
  billingPeriod,
2028
- " quota"
2426
+ " usage"
2029
2427
  ] }),
2030
2428
  /* @__PURE__ */ jsxs(AlertDescription, { children: [
2031
- "Additional usage is being charged at the overage rate",
2032
- rate ? ` ($${Number(rate).toFixed(2)}/call)` : "",
2033
- ". Upgrade to a higher plan for more usage."
2429
+ "Additional usage is billed",
2430
+ view.rateLabel ? ` at ${view.rateLabel}` : "",
2431
+ ". Upgrade to a higher plan for more included usage."
2034
2432
  ] }),
2035
- subscription && /* @__PURE__ */ jsx(AlertAction, { children: /* @__PURE__ */ jsx(SwitchPlanModal, {
2036
- subscription,
2037
- children: /* @__PURE__ */ jsxs(Button, {
2038
- variant: "destructive",
2039
- size: "xs",
2040
- children: [/* @__PURE__ */ jsx(ArrowUpIcon, {}), "Upgrade"]
2041
- })
2042
- }) })
2433
+ upgradeAction("outline")
2043
2434
  ]
2044
2435
  }),
2045
- isAtLimit && !isSoftLimit && /* @__PURE__ */ jsxs(Alert, {
2436
+ atHardLimit && /* @__PURE__ */ jsxs(Alert, {
2046
2437
  variant: "destructive",
2047
2438
  className: "mb-4",
2048
2439
  children: [
@@ -2053,58 +2444,79 @@ const UsageItem = ({ meter, item, subscription, featureKey }) => {
2053
2444
  " limit"
2054
2445
  ] }),
2055
2446
  /* @__PURE__ */ jsx(AlertDescription, { children: "Requests beyond your quota are blocked. Upgrade to a higher plan for more usage." }),
2056
- subscription && /* @__PURE__ */ jsx(AlertAction, { children: /* @__PURE__ */ jsx(SwitchPlanModal, {
2057
- subscription,
2058
- children: /* @__PURE__ */ jsxs(Button, {
2059
- variant: "destructive",
2060
- size: "xs",
2061
- children: [/* @__PURE__ */ jsx(ArrowUpIcon, {}), "Upgrade"]
2062
- })
2063
- }) })
2447
+ upgradeAction("destructive")
2064
2448
  ]
2065
2449
  }),
2066
2450
  /* @__PURE__ */ jsx(CardTitle, { children: item?.name ?? featureKey })
2067
- ] }), /* @__PURE__ */ jsxs(CardContent, {
2451
+ ] }), /* @__PURE__ */ jsx(CardContent, {
2068
2452
  className: "space-y-2",
2069
- children: [
2453
+ children: view.kind === "capped" || view.kind === "included" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
2070
2454
  /* @__PURE__ */ jsxs("div", {
2071
2455
  className: "flex items-center justify-between text-sm",
2072
2456
  children: [/* @__PURE__ */ jsx("div", {
2073
2457
  className: "flex flex-col gap-2 mb-2",
2074
2458
  children: /* @__PURE__ */ jsxs("span", {
2075
- className: cn(dangerZone && "text-red-600 font-medium"),
2459
+ className: cn(atHardLimit && "text-red-600 font-medium"),
2076
2460
  children: [
2077
- meter.usage.toLocaleString(),
2461
+ view.usage.toLocaleString(),
2078
2462
  " used",
2079
- hasOverage && isSoftLimit && /* @__PURE__ */ jsxs("span", {
2463
+ view.kind === "included" && view.overage > 0 && /* @__PURE__ */ jsxs("span", {
2080
2464
  className: "ml-1 text-xs",
2081
2465
  children: [
2082
2466
  "(+",
2083
- meter.overage.toLocaleString(),
2467
+ view.overage.toLocaleString(),
2084
2468
  " overage)"
2085
2469
  ]
2086
2470
  })
2087
2471
  ]
2088
2472
  })
2089
- }), /* @__PURE__ */ jsxs("span", {
2473
+ }), /* @__PURE__ */ jsx("span", {
2090
2474
  className: "text-foreground font-medium",
2091
- children: [limit.toLocaleString(), " limit"]
2475
+ children: view.kind === "capped" ? `${view.quota.toLocaleString()} limit` : `${view.included.toLocaleString()} included`
2092
2476
  })]
2093
2477
  }),
2094
2478
  /* @__PURE__ */ jsx(Progress, {
2095
- value: Math.min(100, limit > 0 ? meter.usage / limit * 100 : 100),
2096
- className: cn("mb-3 h-2", dangerZone && "bg-destructive")
2479
+ value: Math.min(100, (view.kind === "capped" ? view.quota : view.included) > 0 ? view.usage / (view.kind === "capped" ? view.quota : view.included) * 100 : 100),
2480
+ className: cn("mb-3 h-2", atHardLimit && "bg-destructive")
2097
2481
  }),
2098
2482
  /* @__PURE__ */ jsxs("p", {
2099
2483
  className: "text-xs text-muted-foreground",
2100
- children: [meter.balance.toLocaleString(), " remaining this billing period"]
2484
+ children: [
2485
+ view.remaining.toLocaleString(),
2486
+ view.kind === "included" ? " included" : "",
2487
+ " remaining this billing period"
2488
+ ]
2101
2489
  })
2102
- ]
2490
+ ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("div", {
2491
+ className: "flex items-center justify-between text-sm",
2492
+ children: [/* @__PURE__ */ jsxs("span", { children: [view.usage.toLocaleString(), " used this billing period"] }), view.kind === "meteredGeneric" && view.quota !== void 0 ? /* @__PURE__ */ jsxs("span", {
2493
+ className: "text-foreground font-medium",
2494
+ children: [view.quota.toLocaleString(), " quota"]
2495
+ }) : view.rateLabel && /* @__PURE__ */ jsx("span", {
2496
+ className: "text-muted-foreground",
2497
+ children: view.rateLabel
2498
+ })]
2499
+ }), /* @__PURE__ */ jsx("p", {
2500
+ className: "text-xs text-muted-foreground",
2501
+ children: view.caption
2502
+ })] })
2103
2503
  })]
2104
2504
  });
2105
2505
  };
2106
- const Usage = ({ usage, isFetching, currentItems, subscription, isPendingFirstPayment }) => {
2506
+ const Usage = ({ usage, isFetching, currentItems, subscription, isPendingFirstPayment, pendingCredits }) => {
2107
2507
  const hasUsage = Object.values(usage.entitlements).some((value) => isMeteredEntitlement(value));
2508
+ if (!!subscription?.activeTo && new Date(subscription.activeTo).getTime() < Date.now()) return /* @__PURE__ */ jsxs("div", {
2509
+ className: "space-y-4",
2510
+ children: [/* @__PURE__ */ jsx(Heading, {
2511
+ level: 3,
2512
+ children: "Usage"
2513
+ }), /* @__PURE__ */ jsxs(Alert, { children: [
2514
+ /* @__PURE__ */ jsx(HistoryIcon, { className: "size-4 shrink-0" }),
2515
+ /* @__PURE__ */ jsx(AlertTitle, { children: "This subscription has ended" }),
2516
+ /* @__PURE__ */ jsx(AlertDescription, { children: "Usage history isn't available yet." })
2517
+ ] })]
2518
+ });
2519
+ const creditByFeature = new Map((pendingCredits ?? []).map((credit) => [credit.featureKey, credit]));
2108
2520
  return /* @__PURE__ */ jsxs("div", {
2109
2521
  className: "space-y-4",
2110
2522
  children: [
@@ -2143,7 +2555,8 @@ const Usage = ({ usage, isFetching, currentItems, subscription, isPendingFirstPa
2143
2555
  featureKey: key,
2144
2556
  meter: { ...value },
2145
2557
  subscription,
2146
- item: currentItems?.find((item) => item.featureKey === key)
2558
+ item: currentItems?.find((item) => item.featureKey === key),
2559
+ pendingCredit: creditByFeature.get(key)
2147
2560
  }, key) : []) : !isFetching && !subscription?.annotations?.["subscription.previous.id"] ? /* @__PURE__ */ jsxs(Alert, {
2148
2561
  variant: "warning",
2149
2562
  children: [
@@ -2166,6 +2579,7 @@ const ActiveSubscription = ({ subscription, deploymentName }) => {
2166
2579
  refetchOnWindowFocus: true,
2167
2580
  meta: { context: zudoku }
2168
2581
  });
2582
+ const pendingCreditsQuery = usePendingCredits(deploymentName, subscription.id);
2169
2583
  const isPendingFirstPayment = usageQuery.data.paymentStatus.isFirstPayment === true && usageQuery.data.paymentStatus.status !== "paid" && usageQuery.data.paymentStatus.status !== "not_required";
2170
2584
  const activePhase = getActivePhase(subscription);
2171
2585
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
@@ -2184,7 +2598,8 @@ const ActiveSubscription = ({ subscription, deploymentName }) => {
2184
2598
  usage: usageQuery.data,
2185
2599
  isFetching: usageQuery.isFetching,
2186
2600
  subscription,
2187
- isPendingFirstPayment
2601
+ isPendingFirstPayment,
2602
+ pendingCredits: pendingCreditsQuery.data?.pendingCredits
2188
2603
  }),
2189
2604
  subscription?.consumer?.apiKeys && /* @__PURE__ */ jsx(ApiKeysList, {
2190
2605
  isPendingFirstPayment,
@@ -2331,6 +2746,7 @@ const zuploMonetizationPlugin = createPlugin((options = {}) => ({
2331
2746
  if (context.getAuthState().isAuthenticated) queryClient.prefetchQuery(subscriptionsQuery(context));
2332
2747
  },
2333
2748
  getIdentities: async (context) => {
2749
+ if (!context.getAuthState().isAuthenticated) return [];
2334
2750
  return (await queryClient.fetchQuery(subscriptionsQuery(context))).items.flatMap((sub) => sub.status !== "active" ? [] : sub.consumer.apiKeys.flatMap((apiKey) => apiKey.expiresOn && new Date(apiKey.expiresOn) < /* @__PURE__ */ new Date() ? [] : {
2335
2751
  label: `${sub.name} (****${apiKey.key.slice(-5)})`,
2336
2752
  id: apiKey.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zuplo/zudoku-plugin-monetization",
3
- "version": "0.0.43",
3
+ "version": "0.0.45",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zuplo/zudoku",
@@ -33,11 +33,11 @@
33
33
  "@testing-library/react": "16.3.2",
34
34
  "@types/react": "19.2.14",
35
35
  "@types/react-dom": "19.2.3",
36
- "happy-dom": "20.9.0",
36
+ "happy-dom": "20.10.2",
37
37
  "react": "19.2.5",
38
38
  "react-dom": "19.2.5",
39
39
  "tsdown": "0.22.0",
40
- "zudoku": "0.79.1"
40
+ "zudoku": "0.81.0"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "react": ">=19.2.0",