@stigmer/react 3.39.1-dev.20260928194002 → 3.40.1

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 (200) hide show
  1. package/billing/AutoRechargeCard.js +1 -1
  2. package/billing/AutoRechargeCard.js.map +1 -1
  3. package/billing/BillingSection.d.ts +18 -1
  4. package/billing/BillingSection.d.ts.map +1 -1
  5. package/billing/BillingSection.js +21 -11
  6. package/billing/BillingSection.js.map +1 -1
  7. package/billing/ChangePlanDialog.d.ts +46 -0
  8. package/billing/ChangePlanDialog.d.ts.map +1 -0
  9. package/billing/ChangePlanDialog.js +90 -0
  10. package/billing/ChangePlanDialog.js.map +1 -0
  11. package/billing/CreditPackGrid.d.ts +1 -1
  12. package/billing/CreditPackGrid.js +1 -1
  13. package/billing/LowBalanceBanner.js +1 -1
  14. package/billing/LowBalanceBanner.js.map +1 -1
  15. package/billing/PaymentMethodCard.d.ts +9 -1
  16. package/billing/PaymentMethodCard.d.ts.map +1 -1
  17. package/billing/PaymentMethodCard.js +2 -2
  18. package/billing/PaymentMethodCard.js.map +1 -1
  19. package/billing/PlanCard.d.ts +27 -0
  20. package/billing/PlanCard.d.ts.map +1 -0
  21. package/billing/PlanCard.js +68 -0
  22. package/billing/PlanCard.js.map +1 -0
  23. package/billing/PlanPicker.d.ts +34 -0
  24. package/billing/PlanPicker.d.ts.map +1 -0
  25. package/billing/PlanPicker.js +46 -0
  26. package/billing/PlanPicker.js.map +1 -0
  27. package/billing/PlanSection.d.ts +45 -0
  28. package/billing/PlanSection.d.ts.map +1 -0
  29. package/billing/PlanSection.js +136 -0
  30. package/billing/PlanSection.js.map +1 -0
  31. package/billing/UpgradeNotice.d.ts +47 -0
  32. package/billing/UpgradeNotice.d.ts.map +1 -0
  33. package/billing/UpgradeNotice.js +49 -0
  34. package/billing/UpgradeNotice.js.map +1 -0
  35. package/billing/credit-packs.d.ts.map +1 -1
  36. package/billing/credit-packs.js +4 -1
  37. package/billing/credit-packs.js.map +1 -1
  38. package/billing/index.d.ts +26 -1
  39. package/billing/index.d.ts.map +1 -1
  40. package/billing/index.js +12 -0
  41. package/billing/index.js.map +1 -1
  42. package/billing/plan-features.d.ts +60 -0
  43. package/billing/plan-features.d.ts.map +1 -0
  44. package/billing/plan-features.js +128 -0
  45. package/billing/plan-features.js.map +1 -0
  46. package/billing/plan-state.d.ts +43 -0
  47. package/billing/plan-state.d.ts.map +1 -0
  48. package/billing/plan-state.js +69 -0
  49. package/billing/plan-state.js.map +1 -0
  50. package/billing/redirect.d.ts +31 -0
  51. package/billing/redirect.d.ts.map +1 -0
  52. package/billing/redirect.js +30 -0
  53. package/billing/redirect.js.map +1 -0
  54. package/billing/useBillingAccount.d.ts +10 -1
  55. package/billing/useBillingAccount.d.ts.map +1 -1
  56. package/billing/useBillingAccount.js +2 -2
  57. package/billing/useBillingAccount.js.map +1 -1
  58. package/billing/useChangePlan.d.ts +51 -0
  59. package/billing/useChangePlan.d.ts.map +1 -0
  60. package/billing/useChangePlan.js +61 -0
  61. package/billing/useChangePlan.js.map +1 -0
  62. package/billing/useCreateBillingPortalSession.d.ts +6 -3
  63. package/billing/useCreateBillingPortalSession.d.ts.map +1 -1
  64. package/billing/useCreateBillingPortalSession.js +11 -4
  65. package/billing/useCreateBillingPortalSession.js.map +1 -1
  66. package/billing/useCreateCheckoutSession.d.ts +7 -5
  67. package/billing/useCreateCheckoutSession.d.ts.map +1 -1
  68. package/billing/useCreateCheckoutSession.js +5 -3
  69. package/billing/useCreateCheckoutSession.js.map +1 -1
  70. package/billing/useCreatePaymentMethodSetupSession.d.ts +28 -0
  71. package/billing/useCreatePaymentMethodSetupSession.d.ts.map +1 -0
  72. package/billing/useCreatePaymentMethodSetupSession.js +50 -0
  73. package/billing/useCreatePaymentMethodSetupSession.js.map +1 -0
  74. package/billing/useEntitlements.d.ts +46 -0
  75. package/billing/useEntitlements.d.ts.map +1 -0
  76. package/billing/useEntitlements.js +37 -0
  77. package/billing/useEntitlements.js.map +1 -0
  78. package/billing/usePeriodEstimate.d.ts +35 -0
  79. package/billing/usePeriodEstimate.d.ts.map +1 -0
  80. package/billing/usePeriodEstimate.js +29 -0
  81. package/billing/usePeriodEstimate.js.map +1 -0
  82. package/billing/usePlans.d.ts +38 -0
  83. package/billing/usePlans.d.ts.map +1 -0
  84. package/billing/usePlans.js +33 -0
  85. package/billing/usePlans.js.map +1 -0
  86. package/billing/useSubscription.d.ts +39 -0
  87. package/billing/useSubscription.d.ts.map +1 -0
  88. package/billing/useSubscription.js +40 -0
  89. package/billing/useSubscription.js.map +1 -0
  90. package/channel-app/ChannelAppDetailPanel.d.ts +5 -0
  91. package/channel-app/ChannelAppDetailPanel.d.ts.map +1 -1
  92. package/channel-app/ChannelAppDetailPanel.js +9 -3
  93. package/channel-app/ChannelAppDetailPanel.js.map +1 -1
  94. package/channel-app/ChannelAppListPanel.d.ts +13 -4
  95. package/channel-app/ChannelAppListPanel.d.ts.map +1 -1
  96. package/channel-app/ChannelAppListPanel.js +5 -5
  97. package/channel-app/ChannelAppListPanel.js.map +1 -1
  98. package/channel-app/copy.d.ts +15 -0
  99. package/channel-app/copy.d.ts.map +1 -0
  100. package/channel-app/copy.js +15 -0
  101. package/channel-app/copy.js.map +1 -0
  102. package/index.d.ts +4 -2
  103. package/index.d.ts.map +1 -1
  104. package/index.js +4 -1
  105. package/index.js.map +1 -1
  106. package/internal/features.d.ts +11 -0
  107. package/internal/features.d.ts.map +1 -0
  108. package/internal/features.js +42 -0
  109. package/internal/features.js.map +1 -0
  110. package/licenses/license-format.d.ts +5 -3
  111. package/licenses/license-format.d.ts.map +1 -1
  112. package/licenses/license-format.js +10 -19
  113. package/licenses/license-format.js.map +1 -1
  114. package/package.json +5 -5
  115. package/plan-catalog/PlanCatalogConsole.d.ts +22 -0
  116. package/plan-catalog/PlanCatalogConsole.d.ts.map +1 -0
  117. package/plan-catalog/PlanCatalogConsole.js +90 -0
  118. package/plan-catalog/PlanCatalogConsole.js.map +1 -0
  119. package/plan-catalog/PlanCreateForm.d.ts +17 -0
  120. package/plan-catalog/PlanCreateForm.d.ts.map +1 -0
  121. package/plan-catalog/PlanCreateForm.js +52 -0
  122. package/plan-catalog/PlanCreateForm.js.map +1 -0
  123. package/plan-catalog/PlansAccessNotice.d.ts +10 -0
  124. package/plan-catalog/PlansAccessNotice.d.ts.map +1 -0
  125. package/plan-catalog/PlansAccessNotice.js +13 -0
  126. package/plan-catalog/PlansAccessNotice.js.map +1 -0
  127. package/plan-catalog/index.d.ts +8 -0
  128. package/plan-catalog/index.d.ts.map +1 -0
  129. package/plan-catalog/index.js +10 -0
  130. package/plan-catalog/index.js.map +1 -0
  131. package/plan-catalog/plan-draft.d.ts +30 -0
  132. package/plan-catalog/plan-draft.d.ts.map +1 -0
  133. package/plan-catalog/plan-draft.js +91 -0
  134. package/plan-catalog/plan-draft.js.map +1 -0
  135. package/plan-catalog/usePlanMutations.d.ts +42 -0
  136. package/plan-catalog/usePlanMutations.d.ts.map +1 -0
  137. package/plan-catalog/usePlanMutations.js +57 -0
  138. package/plan-catalog/usePlanMutations.js.map +1 -0
  139. package/settings/ChannelAppsSection.d.ts +7 -0
  140. package/settings/ChannelAppsSection.d.ts.map +1 -1
  141. package/settings/ChannelAppsSection.js +15 -2
  142. package/settings/ChannelAppsSection.js.map +1 -1
  143. package/settings/TeamsSection.d.ts.map +1 -1
  144. package/settings/TeamsSection.js +19 -2
  145. package/settings/TeamsSection.js.map +1 -1
  146. package/settings/settings-nav.d.ts.map +1 -1
  147. package/settings/settings-nav.js +7 -1
  148. package/settings/settings-nav.js.map +1 -1
  149. package/settings/useSettingsNavGroups.d.ts.map +1 -1
  150. package/settings/useSettingsNavGroups.js +4 -1
  151. package/settings/useSettingsNavGroups.js.map +1 -1
  152. package/src/billing/AutoRechargeCard.tsx +2 -2
  153. package/src/billing/BillingSection.tsx +68 -11
  154. package/src/billing/ChangePlanDialog.tsx +221 -0
  155. package/src/billing/CreditPackGrid.tsx +1 -1
  156. package/src/billing/LowBalanceBanner.tsx +1 -1
  157. package/src/billing/PaymentMethodCard.tsx +28 -2
  158. package/src/billing/PlanCard.tsx +171 -0
  159. package/src/billing/PlanPicker.tsx +178 -0
  160. package/src/billing/PlanSection.tsx +234 -0
  161. package/src/billing/UpgradeNotice.tsx +95 -0
  162. package/src/billing/credit-packs.ts +4 -1
  163. package/src/billing/index.ts +26 -1
  164. package/src/billing/plan-features.ts +162 -0
  165. package/src/billing/plan-state.ts +92 -0
  166. package/src/billing/redirect.ts +45 -0
  167. package/src/billing/useBillingAccount.ts +12 -0
  168. package/src/billing/useChangePlan.ts +115 -0
  169. package/src/billing/useCreateBillingPortalSession.ts +17 -6
  170. package/src/billing/useCreateCheckoutSession.ts +12 -7
  171. package/src/billing/useCreatePaymentMethodSetupSession.ts +77 -0
  172. package/src/billing/useEntitlements.ts +80 -0
  173. package/src/billing/usePeriodEstimate.ts +59 -0
  174. package/src/billing/usePlans.ts +65 -0
  175. package/src/billing/useSubscription.ts +70 -0
  176. package/src/channel-app/ChannelAppDetailPanel.tsx +179 -158
  177. package/src/channel-app/ChannelAppListPanel.tsx +14 -4
  178. package/src/channel-app/copy.ts +16 -0
  179. package/src/index.ts +54 -0
  180. package/src/internal/features.ts +49 -0
  181. package/src/licenses/license-format.ts +10 -19
  182. package/src/plan-catalog/PlanCatalogConsole.tsx +197 -0
  183. package/src/plan-catalog/PlanCreateForm.tsx +167 -0
  184. package/src/plan-catalog/PlansAccessNotice.tsx +20 -0
  185. package/src/plan-catalog/index.ts +14 -0
  186. package/src/plan-catalog/plan-draft.ts +116 -0
  187. package/src/plan-catalog/usePlanMutations.ts +94 -0
  188. package/src/settings/ChannelAppsSection.tsx +19 -2
  189. package/src/settings/TeamsSection.tsx +20 -2
  190. package/src/settings/settings-nav.ts +7 -0
  191. package/src/settings/useSettingsNavGroups.ts +8 -1
  192. package/src/team/CreateTeamForm.tsx +12 -3
  193. package/src/team/TeamMembersPanel.tsx +6 -2
  194. package/team/CreateTeamForm.d.ts +6 -1
  195. package/team/CreateTeamForm.d.ts.map +1 -1
  196. package/team/CreateTeamForm.js +4 -2
  197. package/team/CreateTeamForm.js.map +1 -1
  198. package/team/TeamMembersPanel.d.ts.map +1 -1
  199. package/team/TeamMembersPanel.js +3 -1
  200. package/team/TeamMembersPanel.js.map +1 -1
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { cn } from "@stigmer/theme";
4
+ import { getErrorReason, getUserMessage } from "@stigmer/sdk";
5
+ import { Feature } from "@stigmer/protos/ai/stigmer/platform/v1/entitlement_pb";
6
+ import { FEATURE_COPY, isNamedFeature } from "../internal/features.js";
7
+
8
+ /**
9
+ * The reason Stigmer Cloud refuses an act the organization's plan does not
10
+ * include (iam/team/v1 and tenancy/organization/v1 command.proto).
11
+ */
12
+ export const PLAN_UPGRADE_REQUIRED = "PLAN_UPGRADE_REQUIRED";
13
+
14
+ /** The billing page the notice links to by default: the settings route every client serves. */
15
+ export const DEFAULT_BILLING_HREF = "/settings/billing";
16
+
17
+ /** The wire names the refusal's `feature` metadata carries. */
18
+ const FEATURE_BY_WIRE_NAME: Readonly<Record<string, Feature>> = {
19
+ teams: Feature.teams,
20
+ managed_organizations: Feature.managed_organizations,
21
+ };
22
+
23
+ /**
24
+ * The feature a refusal says the organization's plan lacks, when `error`
25
+ * is Stigmer Cloud's `PLAN_UPGRADE_REQUIRED`; `null` for any other error.
26
+ */
27
+ export function planUpgradeFeature(error: unknown): Feature | null {
28
+ const reason = getErrorReason(error);
29
+ if (reason?.reason !== PLAN_UPGRADE_REQUIRED) {
30
+ return null;
31
+ }
32
+ return FEATURE_BY_WIRE_NAME[reason.metadata.feature ?? ""] ?? null;
33
+ }
34
+
35
+ /** Props for {@link UpgradeNotice}. */
36
+ export interface UpgradeNoticeProps {
37
+ /** The feature the organization's plan lacks. */
38
+ readonly feature: Feature;
39
+ /**
40
+ * The refusal, when there was one: its message is the server's own
41
+ * copy, which names the plan that unlocks the act. Absent, the notice is
42
+ * shown ahead of an attempt.
43
+ */
44
+ readonly error?: Error | null;
45
+ /**
46
+ * The plan that unlocks the feature, named ahead of an attempt, e.g. the
47
+ * cheapest one that includes it (`lowestPlanWith`). With a refusal,
48
+ * the server's copy names it instead.
49
+ */
50
+ readonly unlockingPlanName?: string;
51
+ /** Where the plans are, in the host's routing. Defaults to {@link DEFAULT_BILLING_HREF}. */
52
+ readonly billingHref?: string;
53
+ /** Additional CSS class names. */
54
+ readonly className?: string;
55
+ }
56
+
57
+ /**
58
+ * Says an act needs a plan that includes it, and where to change the plan,
59
+ * in place of a bare refusal. Everything the organization already has keeps
60
+ * working; only creating more is refused.
61
+ *
62
+ * @example
63
+ * ```tsx
64
+ * const feature = planUpgradeFeature(error);
65
+ * if (feature !== null) return <UpgradeNotice feature={feature} error={error} />;
66
+ * ```
67
+ */
68
+ export function UpgradeNotice({
69
+ feature,
70
+ error,
71
+ unlockingPlanName,
72
+ billingHref = DEFAULT_BILLING_HREF,
73
+ className,
74
+ }: UpgradeNoticeProps) {
75
+ const label = isNamedFeature(feature) ? FEATURE_COPY[feature].label : "This feature";
76
+ const ahead =
77
+ unlockingPlanName === undefined || unlockingPlanName === ""
78
+ ? `${label} are not included in this organization's plan.`
79
+ : `${label} need the ${unlockingPlanName} plan or above.`;
80
+ const message = error ? getUserMessage(error) : ahead;
81
+ return (
82
+ <div
83
+ className={cn(
84
+ "stg:rounded-md stg:border stg:border-border stg:bg-muted-subtle stg:px-3 stg:py-2 stg:text-xs",
85
+ className,
86
+ )}
87
+ role="status"
88
+ >
89
+ <p className="stg:text-foreground">{message}</p>
90
+ <a href={billingHref} className="stg:mt-1 stg:inline-block stg:font-medium stg:text-primary stg:hover:underline">
91
+ View plans
92
+ </a>
93
+ </div>
94
+ );
95
+ }
@@ -34,8 +34,11 @@ export const CREDIT_PACKS: readonly CreditPackInfo[] = [
34
34
  credits: 5_000,
35
35
  },
36
36
  {
37
+ // Named "Scale", not "Team", so it never reads as the Team plan on the
38
+ // page that sells both; its id stays "team", the wire value every
39
+ // purchase row and checkout call carries.
37
40
  packId: "team",
38
- displayName: "Team",
41
+ displayName: "Scale",
39
42
  description: "For production workloads",
40
43
  priceCents: 20_000,
41
44
  credits: 20_000,
@@ -5,13 +5,21 @@
5
5
 
6
6
  // Data hooks
7
7
  export { useBillingAccount } from "./useBillingAccount.js";
8
- export type { UseBillingAccountReturn } from "./useBillingAccount.js";
8
+ export type { UseBillingAccountOptions, UseBillingAccountReturn } from "./useBillingAccount.js";
9
9
  export { useCreditLedger } from "./useCreditLedger.js";
10
10
  export type { UseCreditLedgerReturn, UseCreditLedgerOptions } from "./useCreditLedger.js";
11
11
  export { useBillingUsageReport } from "./useBillingUsageReport.js";
12
12
  export type { UseBillingUsageReportReturn } from "./useBillingUsageReport.js";
13
13
  export { useCustomerModelPricing } from "./useCustomerModelPricing.js";
14
14
  export type { UseCustomerModelPricingReturn } from "./useCustomerModelPricing.js";
15
+ export { usePlans } from "./usePlans.js";
16
+ export type { UsePlansOptions, UsePlansReturn } from "./usePlans.js";
17
+ export { useSubscription } from "./useSubscription.js";
18
+ export type { UseSubscriptionReturn } from "./useSubscription.js";
19
+ export { useEntitlements } from "./useEntitlements.js";
20
+ export type { UseEntitlementsOptions, UseEntitlementsReturn } from "./useEntitlements.js";
21
+ export { usePeriodEstimate } from "./usePeriodEstimate.js";
22
+ export type { UsePeriodEstimateOptions, UsePeriodEstimateReturn } from "./usePeriodEstimate.js";
15
23
 
16
24
  // Behavior hooks
17
25
  export { useCreateCheckoutSession } from "./useCreateCheckoutSession.js";
@@ -21,6 +29,13 @@ export type {
21
29
  } from "./useCreateCheckoutSession.js";
22
30
  export { useCreateBillingPortalSession } from "./useCreateBillingPortalSession.js";
23
31
  export type { UseCreateBillingPortalSessionReturn } from "./useCreateBillingPortalSession.js";
32
+ export { useChangePlan, useCancelSubscription } from "./useChangePlan.js";
33
+ export type { UseChangePlanReturn, UseCancelSubscriptionReturn } from "./useChangePlan.js";
34
+ export { useCreatePaymentMethodSetupSession } from "./useCreatePaymentMethodSetupSession.js";
35
+ export type { UseCreatePaymentMethodSetupSessionReturn } from "./useCreatePaymentMethodSetupSession.js";
36
+ export type { BillingRedirect } from "./redirect.js";
37
+ export { planStanding, planMove } from "./plan-state.js";
38
+ export type { PlanStanding, PlanMove } from "./plan-state.js";
24
39
  export { useSetAutoRechargeConfig } from "./useSetAutoRechargeConfig.js";
25
40
  export type {
26
41
  SetAutoRechargeConfigInput,
@@ -42,6 +57,16 @@ export { CreditLedgerTable } from "./CreditLedgerTable.js";
42
57
  export type { CreditLedgerTableProps } from "./CreditLedgerTable.js";
43
58
  export { LowBalanceBanner } from "./LowBalanceBanner.js";
44
59
  export type { LowBalanceBannerProps } from "./LowBalanceBanner.js";
60
+ export { PlanSection } from "./PlanSection.js";
61
+ export type { PlanSectionProps } from "./PlanSection.js";
62
+ export { PlanCard } from "./PlanCard.js";
63
+ export type { PlanCardProps } from "./PlanCard.js";
64
+ export { PlanPicker, ENTERPRISE_CONTACT_URL } from "./PlanPicker.js";
65
+ export type { PlanPickerProps } from "./PlanPicker.js";
66
+ export { ChangePlanDialog, PAYMENT_METHOD_REQUIRED } from "./ChangePlanDialog.js";
67
+ export type { ChangePlanDialogProps } from "./ChangePlanDialog.js";
68
+ export { UpgradeNotice, planUpgradeFeature, PLAN_UPGRADE_REQUIRED, DEFAULT_BILLING_HREF } from "./UpgradeNotice.js";
69
+ export type { UpgradeNoticeProps } from "./UpgradeNotice.js";
45
70
 
46
71
  // Credit pack catalog and formatting utilities
47
72
  export { CREDIT_PACKS, formatPackPrice, formatCreditCount } from "./credit-packs.js";
@@ -0,0 +1,162 @@
1
+ // What a plan offers, as the console shows it: which plans are offered and
2
+ // in what order, what each includes, and the formatting of its terms and
3
+ // periods.
4
+ //
5
+ // A plan's features render from its entitlements through the shared
6
+ // vocabulary (../internal/features.ts), never from the plan's stored
7
+ // description: a plan row is immutable once seeded, and the description is
8
+ // free text that can promise a feature before it is built. The console
9
+ // shows only what an organization can use today.
10
+
11
+ import type { Timestamp } from "@bufbuild/protobuf/wkt";
12
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
13
+ import type { Plan } from "@stigmer/protos/ai/stigmer/billing/plan/v1/api_pb";
14
+ import { PlanInstrument, type PlanTerms } from "@stigmer/protos/ai/stigmer/billing/plan/v1/spec_pb";
15
+ import { PlanLifecycle } from "@stigmer/protos/ai/stigmer/billing/plan/v1/status_pb";
16
+ import { Feature, type Entitlements } from "@stigmer/protos/ai/stigmer/platform/v1/entitlement_pb";
17
+ import { FEATURE_COPY, isNamedFeature, type NamedFeature } from "../internal/features.js";
18
+ import { formatCreditBalance } from "./format.js";
19
+
20
+ // BigInt(0), not a literal: the docs site compiles this source for a
21
+ // target below ES2020, which has no BigInt literal syntax.
22
+ const ZERO = BigInt(0);
23
+
24
+ /**
25
+ * Features a plan may list ahead of the gate that serves them, which
26
+ * Stigmer Cloud does not offer yet. The console leaves them out of every
27
+ * comparison. Bring-your-own provider keys leaves this set when the proxy
28
+ * uses an organization's own key.
29
+ */
30
+ export const NOT_YET_OFFERED: ReadonlySet<Feature> = new Set([Feature.byo_provider_keys]);
31
+
32
+ /** One feature a plan offers today, with its words. */
33
+ export interface OfferedFeature {
34
+ readonly feature: NamedFeature;
35
+ readonly label: string;
36
+ readonly description: string;
37
+ }
38
+
39
+ /** The features `entitlements` grant that Cloud offers today, in contract order. */
40
+ export function offeredFeatures(entitlements: Entitlements | undefined): readonly OfferedFeature[] {
41
+ const listed = entitlements?.features ?? [];
42
+ return [...new Set(listed)]
43
+ .filter((feature): feature is NamedFeature => isNamedFeature(feature) && !NOT_YET_OFFERED.has(feature))
44
+ .sort((a, b) => a - b)
45
+ .map((feature) => ({ feature, ...FEATURE_COPY[feature] }));
46
+ }
47
+
48
+ /**
49
+ * The plans an organization can subscribe to, cheapest monthly minimum
50
+ * first, so a comparison reads upward from Free; equal minimums keep the
51
+ * catalog's order. Retired rows and prepaid instruments are left out.
52
+ */
53
+ export function buyablePlans(plans: readonly Plan[]): readonly Plan[] {
54
+ return plans
55
+ .filter(
56
+ (plan) =>
57
+ plan.spec?.instrument === PlanInstrument.subscription && plan.status?.lifecycle === PlanLifecycle.active,
58
+ )
59
+ .map((plan, index) => ({ plan, index }))
60
+ .sort((a, b) => compareMinimums(a.plan, b.plan) || a.index - b.index)
61
+ .map(({ plan }) => plan);
62
+ }
63
+
64
+ /** The cheapest plan that can be bought and includes `feature`, if any. */
65
+ export function lowestPlanWith(plans: readonly Plan[], feature: Feature): Plan | undefined {
66
+ return buyablePlans(plans).find((plan) =>
67
+ offeredFeatures(plan.spec?.entitlements).some((offered) => offered.feature === feature),
68
+ );
69
+ }
70
+
71
+ /** What `from` offers that `to` does not: what a switch stops the organization creating. */
72
+ export function featuresLost(from: Plan | undefined, to: Plan): readonly OfferedFeature[] {
73
+ const kept = new Set(offeredFeatures(to.spec?.entitlements).map((offered) => offered.feature));
74
+ return offeredFeatures(from?.spec?.entitlements).filter((offered) => !kept.has(offered.feature));
75
+ }
76
+
77
+ function compareMinimums(a: Plan, b: Plan): number {
78
+ const left = a.spec?.terms?.monthlyMinimumMicros ?? ZERO;
79
+ const right = b.spec?.terms?.monthlyMinimumMicros ?? ZERO;
80
+ return left === right ? 0 : left < right ? -1 : 1;
81
+ }
82
+
83
+ /** A plan's monthly minimum, e.g. "$99.00/month". */
84
+ export function formatMonthlyMinimum(terms: PlanTerms | undefined): string {
85
+ return `${formatCreditBalance(terms?.monthlyMinimumMicros ?? ZERO)}/month`;
86
+ }
87
+
88
+ /**
89
+ * A plan's usage share as its terms state it, for operators who read terms,
90
+ * e.g. "or 10% of provider cost, whichever is greater; the commission
91
+ * already paid on tokens counts toward it". Empty when the plan has no
92
+ * usage share.
93
+ */
94
+ export function formatUsageShare(terms: PlanTerms | undefined): string {
95
+ const percent = sharePercent(terms);
96
+ return percent === ""
97
+ ? ""
98
+ : `or ${percent} of provider cost, whichever is greater; the commission already paid on tokens counts toward it`;
99
+ }
100
+
101
+ /**
102
+ * The same share as a customer reads it: usage stays paid from credits,
103
+ * and the commission on it counts toward the minimum up to the spend at
104
+ * which the plan costs nothing extra, e.g. "Usage is still paid from
105
+ * credits. The 10% commission on it counts toward the minimum, so from
106
+ * $990.00 a month in provider costs the plan costs nothing extra." Empty
107
+ * when the plan has no usage share.
108
+ */
109
+ export function explainUsageShare(terms: PlanTerms | undefined): string {
110
+ const percent = sharePercent(terms);
111
+ if (percent === "") {
112
+ return "";
113
+ }
114
+ const basisPoints = BigInt(terms?.usageShareBasisPoints ?? 0);
115
+ // The provider cost whose share equals the minimum: past it, the share is the plan's cost and the commission pays it.
116
+ const breakEven = ((terms?.monthlyMinimumMicros ?? ZERO) * BigInt(10_000)) / basisPoints;
117
+ return (
118
+ `Usage is still paid from credits. The ${percent} commission on it counts toward the minimum, ` +
119
+ `so from ${formatCreditBalance(breakEven)} a month in provider costs the plan costs nothing extra.`
120
+ );
121
+ }
122
+
123
+ /** The usage share as a percentage, e.g. "10%" or "12.50%"; empty when there is none. */
124
+ export function sharePercent(terms: PlanTerms | undefined): string {
125
+ const basisPoints = terms?.usageShareBasisPoints ?? 0;
126
+ if (basisPoints === 0) {
127
+ return "";
128
+ }
129
+ return `${basisPoints % 100 === 0 ? `${basisPoints / 100}` : (basisPoints / 100).toFixed(2)}%`;
130
+ }
131
+
132
+ /** The managed organizations a plan includes and what each beyond costs, or empty. */
133
+ export function formatManagedOrganizations(
134
+ entitlements: Entitlements | undefined,
135
+ terms: PlanTerms | undefined,
136
+ ): string {
137
+ if (!(entitlements?.features ?? []).includes(Feature.managed_organizations)) {
138
+ return "";
139
+ }
140
+ const included = entitlements?.limits?.includedManagedOrganizations;
141
+ const fee = terms?.perExtraOrganizationMicros ?? ZERO;
142
+ if (included === undefined) {
143
+ return "Unlimited managed organizations";
144
+ }
145
+ const beyond = fee > ZERO ? `, then ${formatCreditBalance(fee)}/month each` : "";
146
+ return `${included} managed organization${included === 1 ? "" : "s"} included${beyond}`;
147
+ }
148
+
149
+ const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
150
+
151
+ /**
152
+ * A period boundary as a UTC calendar day, e.g. "Feb 28, 2027". Periods
153
+ * run anniversary to anniversary in UTC, the rule the invoice follows.
154
+ */
155
+ export function formatPeriodDay(timestamp: Timestamp | undefined): string {
156
+ return timestamp === undefined ? "" : formatDay(timestampDate(timestamp));
157
+ }
158
+
159
+ /** An instant as its UTC calendar day, the way {@link formatPeriodDay} shows a period boundary. */
160
+ export function formatDay(date: Date): string {
161
+ return DAY.format(date);
162
+ }
@@ -0,0 +1,92 @@
1
+ // Where an organization stands on its plan, and what choosing a plan
2
+ // would do, decided the way the server decides it (the subscription
3
+ // engine's moves), so the confirm step states the move the server makes.
4
+ //
5
+ // subscribe no live plan: a monthly period opens now on the plan
6
+ // switch a live plan, another chosen: this period closes early,
7
+ // billed for the time it ran, and a new one opens now
8
+ // resume the plan being canceled, chosen again: the cancel is
9
+ // withdrawn and the period runs on
10
+ // cancel Free chosen on a live plan: the plan runs to its period's
11
+ // end, then the organization is on Free
12
+ //
13
+ // Choosing the plan already in force, or Free while on Free or already
14
+ // canceling, is no move at all.
15
+
16
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
17
+ import type { Plan } from "@stigmer/protos/ai/stigmer/billing/plan/v1/api_pb";
18
+ import type { Subscription } from "@stigmer/protos/ai/stigmer/billing/subscription/v1/api_pb";
19
+ import { SubscriptionState } from "@stigmer/protos/ai/stigmer/billing/subscription/v1/status_pb";
20
+
21
+ /** An organization's standing on its plan. */
22
+ export type PlanStanding =
23
+ | { readonly kind: "free" }
24
+ | { readonly kind: "active"; readonly planId: string }
25
+ | { readonly kind: "past-due"; readonly planId: string }
26
+ | { readonly kind: "ending"; readonly planId: string; readonly endsAt: Date };
27
+
28
+ /** What choosing a plan (or Free) would do. */
29
+ export type PlanMove =
30
+ | { readonly kind: "subscribe"; readonly to: Plan }
31
+ | { readonly kind: "switch"; readonly fromPlanId: string; readonly to: Plan }
32
+ | { readonly kind: "resume"; readonly to: Plan }
33
+ | { readonly kind: "cancel"; readonly fromPlanId: string; readonly endsAt: Date };
34
+
35
+ /**
36
+ * The standing a stored subscription gives at `now`: the engine's live
37
+ * rule (active, past due, or canceled but not yet at its period's end),
38
+ * else Free. `null` (never subscribed) is Free.
39
+ */
40
+ export function planStanding(subscription: Subscription | null, now: Date): PlanStanding {
41
+ const status = subscription?.status;
42
+ const planId = subscription?.spec?.planId ?? "";
43
+ switch (status?.state) {
44
+ case SubscriptionState.active:
45
+ return { kind: "active", planId };
46
+ case SubscriptionState.past_due:
47
+ return { kind: "past-due", planId };
48
+ case SubscriptionState.canceled: {
49
+ const end = status.currentPeriodEnd === undefined ? undefined : timestampDate(status.currentPeriodEnd);
50
+ return end !== undefined && now.getTime() < end.getTime()
51
+ ? { kind: "ending", planId, endsAt: end }
52
+ : { kind: "free" };
53
+ }
54
+ default:
55
+ return { kind: "free" };
56
+ }
57
+ }
58
+
59
+ /** The move choosing `target` (a plan, or `"free"`) makes from `standing`, or `null` for none. */
60
+ export function planMove(
61
+ standing: PlanStanding,
62
+ target: Plan | "free",
63
+ periodEnd: Date | undefined,
64
+ ): PlanMove | null {
65
+ if (target === "free") {
66
+ if (standing.kind === "active" || standing.kind === "past-due") {
67
+ return periodEnd === undefined ? null : { kind: "cancel", fromPlanId: standing.planId, endsAt: periodEnd };
68
+ }
69
+ return null;
70
+ }
71
+ const targetId = target.metadata?.id ?? "";
72
+ switch (standing.kind) {
73
+ case "free":
74
+ return { kind: "subscribe", to: target };
75
+ case "ending":
76
+ return targetId === standing.planId
77
+ ? { kind: "resume", to: target }
78
+ : { kind: "switch", fromPlanId: standing.planId, to: target };
79
+ case "active":
80
+ case "past-due":
81
+ return targetId === standing.planId ? null : { kind: "switch", fromPlanId: standing.planId, to: target };
82
+ default: {
83
+ const exhaustive: never = standing;
84
+ throw new Error(`unknown plan standing ${JSON.stringify(exhaustive)}`);
85
+ }
86
+ }
87
+ }
88
+
89
+ /** The plan id in force for `standing`, or empty on Free. */
90
+ export function standingPlanId(standing: PlanStanding): string {
91
+ return standing.kind === "free" ? "" : standing.planId;
92
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Where a billing flow leaves the app for a Stripe-hosted page, and where
3
+ * Stripe sends the person back.
4
+ *
5
+ * By default the console navigates its own window to Stripe and Stripe
6
+ * returns to the host's billing page on the same origin: right for a web
7
+ * console. A host whose window cannot host a third-party page, such as a
8
+ * desktop webview whose origin is not a web address Stripe can return to,
9
+ * passes a {@link BillingRedirect}: `openUrl` opens the page elsewhere
10
+ * (the system browser), and `returnUrl` names a real web page for Stripe
11
+ * to return to. The person finishes there; the host's reads refresh when
12
+ * its window regains focus, because the state lives on the server.
13
+ */
14
+
15
+ /** The host's seam for leaving the app to a Stripe-hosted page. */
16
+ export interface BillingRedirect {
17
+ /**
18
+ * Opens a Stripe-hosted page. Absent, the current window navigates to
19
+ * it. Desktop hosts open the system browser.
20
+ */
21
+ readonly openUrl?: (url: string) => void | Promise<void>;
22
+ /**
23
+ * The absolute URL of the billing page Stripe returns to, without a
24
+ * query. Absent, the current origin's `/settings/billing`.
25
+ */
26
+ readonly returnUrl?: string;
27
+ }
28
+
29
+ /** The billing page Stripe returns to, per the redirect or the current origin. */
30
+ export function billingReturnUrl(redirect: BillingRedirect | undefined): string {
31
+ if (redirect?.returnUrl !== undefined) {
32
+ return redirect.returnUrl;
33
+ }
34
+ const origin = typeof window === "undefined" ? "" : window.location.origin;
35
+ return `${origin}/settings/billing`;
36
+ }
37
+
38
+ /** Leaves for a Stripe-hosted page through the redirect, or by navigating this window. */
39
+ export async function leaveForStripe(url: string, redirect: BillingRedirect | undefined): Promise<void> {
40
+ if (redirect?.openUrl !== undefined) {
41
+ await redirect.openUrl(url);
42
+ return;
43
+ }
44
+ window.location.href = url;
45
+ }
@@ -8,6 +8,16 @@ import type { BillingAccount } from "@stigmer/protos/ai/stigmer/billing/v1/billi
8
8
  import { useStigmer } from "../hooks.js";
9
9
  import { useFetch } from "../internal/useFetch.js";
10
10
 
11
+ /** Options for {@link useBillingAccount}. */
12
+ export interface UseBillingAccountOptions {
13
+ /**
14
+ * Re-read the account when the window regains focus: for a host that
15
+ * sends Stripe's pages to another window (the system browser), so a card
16
+ * saved there shows on return. Default `false`.
17
+ */
18
+ readonly refetchOnWindowFocus?: boolean;
19
+ }
20
+
11
21
  /** Return value of {@link useBillingAccount}. */
12
22
  export interface UseBillingAccountReturn {
13
23
  /** The billing account, or `null` before the first successful fetch. */
@@ -48,6 +58,7 @@ export interface UseBillingAccountReturn {
48
58
  */
49
59
  export function useBillingAccount(
50
60
  orgId: string | null,
61
+ options?: UseBillingAccountOptions,
51
62
  ): UseBillingAccountReturn {
52
63
  const stigmer = useStigmer();
53
64
 
@@ -58,6 +69,7 @@ export function useBillingAccount(
58
69
  : null,
59
70
  [orgId, stigmer],
60
71
  null as BillingAccount | null,
72
+ { refetchOnWindowFocus: options?.refetchOnWindowFocus ?? false },
61
73
  );
62
74
 
63
75
  return { account, isLoading, isRefetching, error, refetch };
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import type { Subscription } from "@stigmer/protos/ai/stigmer/billing/subscription/v1/api_pb";
6
+ import {
7
+ CancelSubscriptionInputSchema,
8
+ ChangePlanInputSchema,
9
+ } from "@stigmer/protos/ai/stigmer/billing/subscription/v1/io_pb";
10
+ import { useStigmer } from "../hooks.js";
11
+ import { toError } from "../internal/toError.js";
12
+
13
+ /** Return value of {@link useChangePlan}. */
14
+ export interface UseChangePlanReturn {
15
+ /**
16
+ * Move the organization onto a plan: subscribe (from Free), switch, or
17
+ * resume a canceled plan by choosing it again. A switch takes effect
18
+ * now: the current period closes early, billed for the time it ran, and
19
+ * a new monthly period opens on the new plan. Nothing is charged at the
20
+ * moment of the change; every period is collected when it closes.
21
+ *
22
+ * Refused with the `PAYMENT_METHOD_REQUIRED` reason when the
23
+ * organization has no saved card (see
24
+ * {@link useCreatePaymentMethodSetupSession}). Resolves with the
25
+ * subscription as it stands after the change.
26
+ */
27
+ readonly changePlan: (orgId: string, planId: string) => Promise<Subscription>;
28
+ /** `true` while a change is in flight. */
29
+ readonly isSubmitting: boolean;
30
+ /** Error from the last failed attempt, or `null` when healthy. */
31
+ readonly error: Error | null;
32
+ /** Reset `error` to `null`. */
33
+ readonly clearError: () => void;
34
+ }
35
+
36
+ /**
37
+ * Behaviour hook that moves an organization onto a plan.
38
+ *
39
+ * The caller needs `can_manage_billing` on the organization (its admins).
40
+ * Refetch the subscription, entitlements and estimate after a change.
41
+ */
42
+ export function useChangePlan(): UseChangePlanReturn {
43
+ const stigmer = useStigmer();
44
+ const [isSubmitting, setIsSubmitting] = useState(false);
45
+ const [error, setError] = useState<Error | null>(null);
46
+
47
+ const changePlan = useCallback(
48
+ async (orgId: string, planId: string): Promise<Subscription> => {
49
+ setIsSubmitting(true);
50
+ setError(null);
51
+ try {
52
+ return await stigmer.subscription.changePlan(create(ChangePlanInputSchema, { orgId, planId }));
53
+ } catch (e) {
54
+ const err = toError(e);
55
+ setError(err);
56
+ throw err;
57
+ } finally {
58
+ setIsSubmitting(false);
59
+ }
60
+ },
61
+ [stigmer.subscription],
62
+ );
63
+ const clearError = useCallback(() => setError(null), []);
64
+
65
+ return useMemo(
66
+ () => ({ changePlan, isSubmitting, error, clearError }),
67
+ [changePlan, isSubmitting, error, clearError],
68
+ );
69
+ }
70
+
71
+ /** Return value of {@link useCancelSubscription}. */
72
+ export interface UseCancelSubscriptionReturn {
73
+ /**
74
+ * Cancel the organization's subscription. It keeps its plan to the end
75
+ * of the current period and is on Free after it; nothing it built is
76
+ * deleted. Choosing the same plan again before then resumes it.
77
+ */
78
+ readonly cancel: (orgId: string) => Promise<Subscription>;
79
+ /** `true` while a cancel is in flight. */
80
+ readonly isSubmitting: boolean;
81
+ /** Error from the last failed attempt, or `null` when healthy. */
82
+ readonly error: Error | null;
83
+ /** Reset `error` to `null`. */
84
+ readonly clearError: () => void;
85
+ }
86
+
87
+ /**
88
+ * Behaviour hook that cancels an organization's subscription at the end
89
+ * of its period. The caller needs `can_manage_billing` on the organization.
90
+ */
91
+ export function useCancelSubscription(): UseCancelSubscriptionReturn {
92
+ const stigmer = useStigmer();
93
+ const [isSubmitting, setIsSubmitting] = useState(false);
94
+ const [error, setError] = useState<Error | null>(null);
95
+
96
+ const cancel = useCallback(
97
+ async (orgId: string): Promise<Subscription> => {
98
+ setIsSubmitting(true);
99
+ setError(null);
100
+ try {
101
+ return await stigmer.subscription.cancel(create(CancelSubscriptionInputSchema, { orgId }));
102
+ } catch (e) {
103
+ const err = toError(e);
104
+ setError(err);
105
+ throw err;
106
+ } finally {
107
+ setIsSubmitting(false);
108
+ }
109
+ },
110
+ [stigmer.subscription],
111
+ );
112
+ const clearError = useCallback(() => setError(null), []);
113
+
114
+ return useMemo(() => ({ cancel, isSubmitting, error, clearError }), [cancel, isSubmitting, error, clearError]);
115
+ }
@@ -3,14 +3,17 @@
3
3
  import { useCallback, useState } from "react";
4
4
  import { useStigmer } from "../hooks.js";
5
5
  import { toError } from "../internal/toError.js";
6
+ import { billingReturnUrl, leaveForStripe, type BillingRedirect } from "./redirect.js";
6
7
 
7
8
  /** Return value of {@link useCreateBillingPortalSession}. */
8
9
  export interface UseCreateBillingPortalSessionReturn {
9
10
  /**
10
11
  * Open the Stripe Customer Portal for payment method management.
11
12
  *
12
- * On success, redirects the user to the Stripe-hosted portal page.
13
- * The promise resolves before the redirect occurs.
13
+ * On success, leaves for the Stripe-hosted portal page: through
14
+ * `redirect.openUrl` when given, else by navigating this window. The
15
+ * portal returns to `redirect.returnUrl` when given, else to the page
16
+ * the person is on.
14
17
  */
15
18
  readonly openPortal: (orgId: string) => Promise<void>;
16
19
  /** `true` while the portal session is being created. */
@@ -38,8 +41,12 @@ export interface UseCreateBillingPortalSessionReturn {
38
41
  * </button>
39
42
  * ```
40
43
  */
41
- export function useCreateBillingPortalSession(): UseCreateBillingPortalSessionReturn {
44
+ export function useCreateBillingPortalSession(
45
+ redirect?: BillingRedirect,
46
+ ): UseCreateBillingPortalSessionReturn {
42
47
  const stigmer = useStigmer();
48
+ const openUrl = redirect?.openUrl;
49
+ const pinnedReturnUrl = redirect?.returnUrl;
43
50
  const [isLoading, setIsLoading] = useState(false);
44
51
  const [error, setError] = useState<Error | null>(null);
45
52
 
@@ -52,7 +59,11 @@ export function useCreateBillingPortalSession(): UseCreateBillingPortalSessionRe
52
59
 
53
60
  try {
54
61
  const returnUrl =
55
- typeof window !== "undefined" ? window.location.href : "";
62
+ pinnedReturnUrl !== undefined
63
+ ? billingReturnUrl({ returnUrl: pinnedReturnUrl })
64
+ : typeof window !== "undefined"
65
+ ? window.location.href
66
+ : "";
56
67
 
57
68
  const response = await stigmer.billing.createBillingPortalSession({
58
69
  orgId,
@@ -60,7 +71,7 @@ export function useCreateBillingPortalSession(): UseCreateBillingPortalSessionRe
60
71
  });
61
72
 
62
73
  if (response.portalUrl) {
63
- window.location.href = response.portalUrl;
74
+ await leaveForStripe(response.portalUrl, { openUrl });
64
75
  }
65
76
  } catch (err) {
66
77
  setError(toError(err));
@@ -69,7 +80,7 @@ export function useCreateBillingPortalSession(): UseCreateBillingPortalSessionRe
69
80
  setIsLoading(false);
70
81
  }
71
82
  },
72
- [stigmer],
83
+ [stigmer, openUrl, pinnedReturnUrl],
73
84
  );
74
85
 
75
86
  return { openPortal, isLoading, error, clearError };