@stigmer/react 3.39.1-dev.20260928194002 → 3.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) 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/index.d.ts +4 -2
  91. package/index.d.ts.map +1 -1
  92. package/index.js +4 -1
  93. package/index.js.map +1 -1
  94. package/internal/features.d.ts +11 -0
  95. package/internal/features.d.ts.map +1 -0
  96. package/internal/features.js +42 -0
  97. package/internal/features.js.map +1 -0
  98. package/licenses/license-format.d.ts +5 -3
  99. package/licenses/license-format.d.ts.map +1 -1
  100. package/licenses/license-format.js +10 -19
  101. package/licenses/license-format.js.map +1 -1
  102. package/package.json +5 -5
  103. package/plan-catalog/PlanCatalogConsole.d.ts +22 -0
  104. package/plan-catalog/PlanCatalogConsole.d.ts.map +1 -0
  105. package/plan-catalog/PlanCatalogConsole.js +90 -0
  106. package/plan-catalog/PlanCatalogConsole.js.map +1 -0
  107. package/plan-catalog/PlanCreateForm.d.ts +17 -0
  108. package/plan-catalog/PlanCreateForm.d.ts.map +1 -0
  109. package/plan-catalog/PlanCreateForm.js +52 -0
  110. package/plan-catalog/PlanCreateForm.js.map +1 -0
  111. package/plan-catalog/PlansAccessNotice.d.ts +10 -0
  112. package/plan-catalog/PlansAccessNotice.d.ts.map +1 -0
  113. package/plan-catalog/PlansAccessNotice.js +13 -0
  114. package/plan-catalog/PlansAccessNotice.js.map +1 -0
  115. package/plan-catalog/index.d.ts +8 -0
  116. package/plan-catalog/index.d.ts.map +1 -0
  117. package/plan-catalog/index.js +10 -0
  118. package/plan-catalog/index.js.map +1 -0
  119. package/plan-catalog/plan-draft.d.ts +30 -0
  120. package/plan-catalog/plan-draft.d.ts.map +1 -0
  121. package/plan-catalog/plan-draft.js +91 -0
  122. package/plan-catalog/plan-draft.js.map +1 -0
  123. package/plan-catalog/usePlanMutations.d.ts +42 -0
  124. package/plan-catalog/usePlanMutations.d.ts.map +1 -0
  125. package/plan-catalog/usePlanMutations.js +57 -0
  126. package/plan-catalog/usePlanMutations.js.map +1 -0
  127. package/settings/TeamsSection.d.ts.map +1 -1
  128. package/settings/TeamsSection.js +19 -2
  129. package/settings/TeamsSection.js.map +1 -1
  130. package/settings/settings-nav.d.ts.map +1 -1
  131. package/settings/settings-nav.js +7 -1
  132. package/settings/settings-nav.js.map +1 -1
  133. package/settings/useSettingsNavGroups.d.ts.map +1 -1
  134. package/settings/useSettingsNavGroups.js +4 -1
  135. package/settings/useSettingsNavGroups.js.map +1 -1
  136. package/src/billing/AutoRechargeCard.tsx +2 -2
  137. package/src/billing/BillingSection.tsx +68 -11
  138. package/src/billing/ChangePlanDialog.tsx +221 -0
  139. package/src/billing/CreditPackGrid.tsx +1 -1
  140. package/src/billing/LowBalanceBanner.tsx +1 -1
  141. package/src/billing/PaymentMethodCard.tsx +28 -2
  142. package/src/billing/PlanCard.tsx +171 -0
  143. package/src/billing/PlanPicker.tsx +178 -0
  144. package/src/billing/PlanSection.tsx +234 -0
  145. package/src/billing/UpgradeNotice.tsx +95 -0
  146. package/src/billing/credit-packs.ts +4 -1
  147. package/src/billing/index.ts +26 -1
  148. package/src/billing/plan-features.ts +162 -0
  149. package/src/billing/plan-state.ts +92 -0
  150. package/src/billing/redirect.ts +45 -0
  151. package/src/billing/useBillingAccount.ts +12 -0
  152. package/src/billing/useChangePlan.ts +115 -0
  153. package/src/billing/useCreateBillingPortalSession.ts +17 -6
  154. package/src/billing/useCreateCheckoutSession.ts +12 -7
  155. package/src/billing/useCreatePaymentMethodSetupSession.ts +77 -0
  156. package/src/billing/useEntitlements.ts +80 -0
  157. package/src/billing/usePeriodEstimate.ts +59 -0
  158. package/src/billing/usePlans.ts +65 -0
  159. package/src/billing/useSubscription.ts +70 -0
  160. package/src/index.ts +54 -0
  161. package/src/internal/features.ts +49 -0
  162. package/src/licenses/license-format.ts +10 -19
  163. package/src/plan-catalog/PlanCatalogConsole.tsx +197 -0
  164. package/src/plan-catalog/PlanCreateForm.tsx +167 -0
  165. package/src/plan-catalog/PlansAccessNotice.tsx +20 -0
  166. package/src/plan-catalog/index.ts +14 -0
  167. package/src/plan-catalog/plan-draft.ts +116 -0
  168. package/src/plan-catalog/usePlanMutations.ts +94 -0
  169. package/src/settings/TeamsSection.tsx +20 -2
  170. package/src/settings/settings-nav.ts +7 -0
  171. package/src/settings/useSettingsNavGroups.ts +8 -1
  172. package/src/team/CreateTeamForm.tsx +12 -3
  173. package/src/team/TeamMembersPanel.tsx +6 -2
  174. package/team/CreateTeamForm.d.ts +6 -1
  175. package/team/CreateTeamForm.d.ts.map +1 -1
  176. package/team/CreateTeamForm.js +4 -2
  177. package/team/CreateTeamForm.js.map +1 -1
  178. package/team/TeamMembersPanel.d.ts.map +1 -1
  179. package/team/TeamMembersPanel.js +3 -1
  180. package/team/TeamMembersPanel.js.map +1 -1
@@ -4,6 +4,7 @@ import { useCallback, useId, useState } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import { getUserMessage } from "@stigmer/sdk";
6
6
  import { BillingAccountStatus } from "@stigmer/protos/ai/stigmer/billing/v1/enum_pb";
7
+ import { ManagementMode } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/enum_pb";
7
8
  import { useDeploymentMode } from "../deployment-mode.js";
8
9
  import { CloudFeatureNotice } from "../internal/CloudFeatureNotice.js";
9
10
  import { useOrg } from "../organization/OrgProvider.js";
@@ -16,6 +17,9 @@ import { AutoRechargeCard } from "./AutoRechargeCard.js";
16
17
  import { CreditPackGrid } from "./CreditPackGrid.js";
17
18
  import { CreditLedgerTable } from "./CreditLedgerTable.js";
18
19
  import { LowBalanceBanner } from "./LowBalanceBanner.js";
20
+ import { PlanSection } from "./PlanSection.js";
21
+ import { billingReturnUrl, type BillingRedirect } from "./redirect.js";
22
+ import { useCreatePaymentMethodSetupSession } from "./useCreatePaymentMethodSetupSession.js";
19
23
 
20
24
  /** Props for {@link BillingSection}. */
21
25
  export interface BillingSectionProps {
@@ -29,6 +33,22 @@ export interface BillingSectionProps {
29
33
  readonly checkoutSuccess?: boolean;
30
34
  /** Callback to dismiss the checkout success banner. */
31
35
  readonly onDismissCheckoutSuccess?: () => void;
36
+ /**
37
+ * The plan the person was choosing when they left to save a card: the
38
+ * `plan` query Stripe returns with after `?setup=success`. The plan
39
+ * section reopens that choice for an explicit confirm.
40
+ */
41
+ readonly resumePlanId?: string;
42
+ /** Called once the resumed plan choice is taken up, so the host can clear its query. */
43
+ readonly onResumeHandled?: () => void;
44
+ /**
45
+ * The host's seam for Stripe-hosted pages (checkout, card setup and the
46
+ * billing portal). Absent, this window navigates to them and Stripe
47
+ * returns to this origin's `/settings/billing`. A desktop host opens the
48
+ * system browser and names the web console's billing page instead; its
49
+ * reads refresh when the window regains focus.
50
+ */
51
+ readonly redirect?: BillingRedirect;
32
52
  /** Additional CSS class names. */
33
53
  readonly className?: string;
34
54
  }
@@ -51,12 +71,16 @@ export interface BillingSectionProps {
51
71
  export function BillingSection({
52
72
  checkoutSuccess,
53
73
  onDismissCheckoutSuccess,
74
+ resumePlanId,
75
+ onResumeHandled,
76
+ redirect,
54
77
  className,
55
78
  }: BillingSectionProps) {
56
79
  const headingId = useId();
57
80
  const { activeOrg } = useOrg();
58
81
  const mode = useDeploymentMode();
59
82
  const orgId = activeOrg?.metadata?.id ?? "";
83
+ const managed = activeOrg?.spec?.managementMode === ManagementMode.platform_managed;
60
84
 
61
85
  return (
62
86
  <section aria-labelledby={headingId} className={className}>
@@ -67,7 +91,7 @@ export function BillingSection({
67
91
  Billing
68
92
  </h2>
69
93
  <p className="stg:text-muted-foreground stg:mb-4 stg:text-xs">
70
- Manage credits, purchase credit packs, and view transaction history.
94
+ Your plan, credits, payment method and transaction history.
71
95
  </p>
72
96
 
73
97
  {mode === "local" ? (
@@ -83,8 +107,12 @@ export function BillingSection({
83
107
  ) : (
84
108
  <BillingContent
85
109
  orgId={orgId}
110
+ managed={managed}
86
111
  checkoutSuccess={checkoutSuccess}
87
112
  onDismissCheckoutSuccess={onDismissCheckoutSuccess}
113
+ resumePlanId={resumePlanId}
114
+ onResumeHandled={onResumeHandled}
115
+ redirect={redirect}
88
116
  />
89
117
  )}
90
118
  </section>
@@ -97,16 +125,28 @@ export function BillingSection({
97
125
 
98
126
  function BillingContent({
99
127
  orgId,
128
+ managed,
100
129
  checkoutSuccess,
101
130
  onDismissCheckoutSuccess,
131
+ resumePlanId,
132
+ onResumeHandled,
133
+ redirect,
102
134
  }: {
103
135
  orgId: string;
136
+ managed: boolean;
104
137
  checkoutSuccess?: boolean;
105
138
  onDismissCheckoutSuccess?: () => void;
139
+ resumePlanId?: string;
140
+ onResumeHandled?: () => void;
141
+ redirect?: BillingRedirect;
106
142
  }) {
107
- const { account, isLoading, error, refetch } = useBillingAccount(orgId);
108
- const { createSession, isSubmitting, error: checkoutError, clearError } = useCreateCheckoutSession();
109
- const { openPortal, isLoading: isPortalLoading } = useCreateBillingPortalSession();
143
+ const { account, isLoading, error, refetch } = useBillingAccount(orgId, {
144
+ refetchOnWindowFocus: redirect?.openUrl !== undefined,
145
+ });
146
+ const { createSession, isSubmitting, error: checkoutError, clearError } = useCreateCheckoutSession(redirect);
147
+ const { openPortal, isLoading: isPortalLoading } = useCreateBillingPortalSession(redirect);
148
+ const setup = useCreatePaymentMethodSetupSession(redirect);
149
+ const returnUrl = redirect?.returnUrl;
110
150
  const [purchasingPackId, setPurchasingPackId] = useState<string | null>(null);
111
151
 
112
152
  const handlePurchase = useCallback(
@@ -114,8 +154,7 @@ function BillingContent({
114
154
  setPurchasingPackId(packId);
115
155
  clearError();
116
156
 
117
- const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
118
- const billingPath = `${baseUrl}/settings/billing`;
157
+ const billingPath = billingReturnUrl({ returnUrl });
119
158
 
120
159
  createSession({
121
160
  orgId,
@@ -126,7 +165,7 @@ function BillingContent({
126
165
  setPurchasingPackId(null);
127
166
  });
128
167
  },
129
- [orgId, createSession, clearError],
168
+ [orgId, createSession, clearError, returnUrl],
130
169
  );
131
170
 
132
171
  if (isLoading) {
@@ -161,6 +200,8 @@ function BillingContent({
161
200
 
162
201
  const isLowBalance =
163
202
  balance.availableMicros < account.lowBalanceThresholdMicros;
203
+ const hasPaymentMethod =
204
+ account.defaultPaymentMethod != null && account.defaultPaymentMethod.paymentMethodId !== "";
164
205
 
165
206
  return (
166
207
  <div className="stg:space-y-6">
@@ -173,6 +214,16 @@ function BillingContent({
173
214
  thresholdMicros={account.lowBalanceThresholdMicros}
174
215
  />
175
216
 
217
+ <PlanSection
218
+ orgId={orgId}
219
+ managed={managed}
220
+ hasPaymentMethod={hasPaymentMethod}
221
+ onRefreshAccount={refetch}
222
+ redirect={redirect}
223
+ resumePlanId={resumePlanId}
224
+ onResumeHandled={onResumeHandled}
225
+ />
226
+
176
227
  <CreditBalanceCard balance={balance} isLowBalance={isLowBalance} />
177
228
 
178
229
  <PaymentMethodCard
@@ -180,15 +231,21 @@ function BillingContent({
180
231
  accountStatus={account.status}
181
232
  isPortalLoading={isPortalLoading}
182
233
  onManage={() => openPortal(orgId)}
234
+ onAdd={() => {
235
+ setup.addPaymentMethod(orgId).catch(() => undefined);
236
+ }}
237
+ isAdding={setup.isSubmitting}
183
238
  />
239
+ {setup.error && (
240
+ <p className="stg:text-destructive stg:text-xs" role="alert">
241
+ {getUserMessage(setup.error)}
242
+ </p>
243
+ )}
184
244
 
185
245
  <AutoRechargeCard
186
246
  orgId={orgId}
187
247
  autoRecharge={account.autoRecharge}
188
- hasPaymentMethod={
189
- account.defaultPaymentMethod != null &&
190
- account.defaultPaymentMethod.paymentMethodId !== ""
191
- }
248
+ hasPaymentMethod={hasPaymentMethod}
192
249
  accountStatus={account.status}
193
250
  onSaved={refetch}
194
251
  />
@@ -0,0 +1,221 @@
1
+ "use client";
2
+
3
+ import { useId } from "react";
4
+ import { getErrorReason, getUserMessage } from "@stigmer/sdk";
5
+ import type { PlanTerms } from "@stigmer/protos/ai/stigmer/billing/plan/v1/spec_pb";
6
+ import { Button } from "../button/index.js";
7
+ import { DialogShell } from "../internal/DialogShell.js";
8
+ import { formatDay, formatMonthlyMinimum, sharePercent, type OfferedFeature } from "./plan-features.js";
9
+ import type { PlanMove } from "./plan-state.js";
10
+
11
+ /** The reason a plan change is refused for want of a saved card (billing/subscription/v1/command.proto). */
12
+ export const PAYMENT_METHOD_REQUIRED = "PAYMENT_METHOD_REQUIRED";
13
+
14
+ /** Props for {@link ChangePlanDialog}. */
15
+ export interface ChangePlanDialogProps {
16
+ /** The move to confirm; the dialog is open while it is set. */
17
+ readonly move: PlanMove | null;
18
+ /** The display name of a plan by id, for the plan being left. */
19
+ readonly planName: (planId: string) => string;
20
+ /**
21
+ * For a switch, what the plan being left offers that the new one does
22
+ * not (`featuresLost`). Empty, as for an upgrade, the dialog says
23
+ * nothing is lost.
24
+ */
25
+ readonly losing?: readonly OfferedFeature[];
26
+ /**
27
+ * Whether the organization has a saved card. Every move but a cancel
28
+ * needs one, because every period is collected from it.
29
+ */
30
+ readonly hasPaymentMethod: boolean;
31
+ /**
32
+ * The card was just saved on Stripe's page and is on its way to the
33
+ * account: say so instead of asking for one again.
34
+ */
35
+ readonly awaitingPaymentMethod?: boolean;
36
+ /** `true` while the move, or the card page, is in flight. */
37
+ readonly isSubmitting: boolean;
38
+ /** Why the last attempt failed, or `null`. */
39
+ readonly error: Error | null;
40
+ /** Make the move. */
41
+ readonly onConfirm: () => void;
42
+ /** Leave to save a card, returning to this choice. */
43
+ readonly onAddPaymentMethod: () => void;
44
+ /** Close without moving. */
45
+ readonly onClose: () => void;
46
+ }
47
+
48
+ /**
49
+ * The confirm step for a plan change. It states exactly what the move does
50
+ * and when money moves: a subscription starts now and every period is
51
+ * collected when it closes; a switch closes this period early, billed for
52
+ * the time it ran; a cancel keeps the plan to its period's end and deletes
53
+ * nothing. Without a saved card it offers the card page first.
54
+ */
55
+ export function ChangePlanDialog({
56
+ move,
57
+ planName,
58
+ losing = [],
59
+ hasPaymentMethod,
60
+ awaitingPaymentMethod,
61
+ isSubmitting,
62
+ error,
63
+ onConfirm,
64
+ onAddPaymentMethod,
65
+ onClose,
66
+ }: ChangePlanDialogProps) {
67
+ const headingId = useId();
68
+ const needsCard =
69
+ move !== null &&
70
+ move.kind !== "cancel" &&
71
+ (!hasPaymentMethod || getErrorReason(error)?.reason === PAYMENT_METHOD_REQUIRED);
72
+
73
+ return (
74
+ <DialogShell
75
+ open={move !== null}
76
+ onOpenChange={(open) => {
77
+ if (!open) onClose();
78
+ }}
79
+ width="md"
80
+ aria-labelledby={headingId}
81
+ >
82
+ {move && (
83
+ <div className="stg:space-y-3 stg:p-5">
84
+ <h2 id={headingId} className="stg:text-sm stg:font-semibold stg:text-foreground">
85
+ {title(move)}
86
+ </h2>
87
+ <div className="stg:space-y-2 stg:text-xs stg:text-muted-foreground">{body(move, planName, losing)}</div>
88
+
89
+ {needsCard && (
90
+ <p className="stg:rounded-md stg:bg-muted-subtle stg:px-3 stg:py-2 stg:text-xs stg:text-foreground" role="status">
91
+ {awaitingPaymentMethod
92
+ ? "Your card is being saved. This takes a few seconds; the choice is kept."
93
+ : "A saved card is needed first: every period is collected from it. You come back here after saving it."}
94
+ </p>
95
+ )}
96
+ {error && getErrorReason(error)?.reason !== PAYMENT_METHOD_REQUIRED && (
97
+ <p className="stg:text-xs stg:text-destructive" role="alert">
98
+ {getUserMessage(error)}
99
+ </p>
100
+ )}
101
+
102
+ <div className="stg:flex stg:justify-end stg:gap-2 stg:pt-1">
103
+ <Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
104
+ Not now
105
+ </Button>
106
+ {needsCard ? (
107
+ <Button size="sm" onClick={onAddPaymentMethod} disabled={isSubmitting || awaitingPaymentMethod}>
108
+ Add a payment method
109
+ </Button>
110
+ ) : (
111
+ <Button
112
+ variant={move.kind === "cancel" ? "destructive" : "primary"}
113
+ size="sm"
114
+ onClick={onConfirm}
115
+ disabled={isSubmitting}
116
+ >
117
+ {confirmLabel(move)}
118
+ </Button>
119
+ )}
120
+ </div>
121
+ </div>
122
+ )}
123
+ </DialogShell>
124
+ );
125
+ }
126
+
127
+ function title(move: PlanMove): string {
128
+ switch (move.kind) {
129
+ case "subscribe":
130
+ return `Subscribe to ${move.to.metadata?.name ?? "this plan"}`;
131
+ case "switch":
132
+ return `Switch to ${move.to.metadata?.name ?? "this plan"}`;
133
+ case "resume":
134
+ return `Keep ${move.to.metadata?.name ?? "this plan"}`;
135
+ case "cancel":
136
+ return "Move to Free";
137
+ default: {
138
+ const exhaustive: never = move;
139
+ return String(exhaustive);
140
+ }
141
+ }
142
+ }
143
+
144
+ function body(move: PlanMove, planName: (planId: string) => string, losing: readonly OfferedFeature[]) {
145
+ switch (move.kind) {
146
+ case "subscribe":
147
+ return (
148
+ <>
149
+ <p>
150
+ {move.to.metadata?.name} starts now. Each monthly period is billed when it ends:{" "}
151
+ {periodCharge(move.to.spec?.terms)}
152
+ </p>
153
+ <p>Nothing is charged today.</p>
154
+ </>
155
+ );
156
+ case "switch":
157
+ return (
158
+ <>
159
+ <p>
160
+ The switch takes effect now. This period closes early and is billed for the time it ran on{" "}
161
+ {planName(move.fromPlanId)}; a new monthly period opens on {move.to.metadata?.name} at{" "}
162
+ {formatMonthlyMinimum(move.to.spec?.terms)}.
163
+ </p>
164
+ {losing.length > 0 && (
165
+ <p>
166
+ {move.to.metadata?.name} does not include {listFeatures(losing)}. Everything you built keeps working; only
167
+ creating more is refused.
168
+ </p>
169
+ )}
170
+ </>
171
+ );
172
+ case "resume":
173
+ return <p>The cancellation is withdrawn, and {move.to.metadata?.name} runs on into its next period.</p>;
174
+ case "cancel":
175
+ return (
176
+ <>
177
+ <p>
178
+ {planName(move.fromPlanId)} stays in force until {formatDay(move.endsAt)}, then this organization is on
179
+ Free. The period is billed as usual when it closes.
180
+ </p>
181
+ <p>Nothing you built is deleted. Creating what Free does not include is refused until you subscribe again.</p>
182
+ </>
183
+ );
184
+ default: {
185
+ const exhaustive: never = move;
186
+ return String(exhaustive);
187
+ }
188
+ }
189
+ }
190
+
191
+ function confirmLabel(move: PlanMove): string {
192
+ switch (move.kind) {
193
+ case "subscribe":
194
+ return "Subscribe";
195
+ case "switch":
196
+ return "Switch now";
197
+ case "resume":
198
+ return "Keep plan";
199
+ case "cancel":
200
+ return "Cancel plan";
201
+ default: {
202
+ const exhaustive: never = move;
203
+ return String(exhaustive);
204
+ }
205
+ }
206
+ }
207
+
208
+ /** "Teams", "Teams and Managed organizations", "A, B and C". */
209
+ function listFeatures(features: readonly OfferedFeature[]): string {
210
+ const labels = features.map((feature) => feature.label);
211
+ return labels.length <= 1 ? (labels[0] ?? "") : `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`;
212
+ }
213
+
214
+ /** What a period of the plan is billed at its end, in the customer's words. */
215
+ function periodCharge(terms: PlanTerms | undefined): string {
216
+ const minimum = formatMonthlyMinimum(terms);
217
+ const percent = sharePercent(terms);
218
+ return percent === ""
219
+ ? `${minimum}.`
220
+ : `${minimum}, less the ${percent} commission already paid on that period's usage. Usage itself stays paid from credits.`;
221
+ }
@@ -24,7 +24,7 @@ export interface CreditPackGridProps {
24
24
  /**
25
25
  * Grid of credit pack cards with purchase buttons.
26
26
  *
27
- * Displays the 3 self-serve credit packs (Starter, Growth, Team)
27
+ * Displays the 3 self-serve credit packs (Starter, Growth, Scale)
28
28
  * in a responsive grid. Each card shows the pack name, price,
29
29
  * credit count, and a buy button. Buttons are disabled when the
30
30
  * account is suspended/closed or a purchase is in progress.
@@ -58,7 +58,7 @@ export function LowBalanceBanner({
58
58
  </p>
59
59
  <p className="stg:mt-0.5 stg:opacity-80">
60
60
  {isZeroOrNegative
61
- ? "Your credit balance is zero. Purchase credits to continue running agent executions."
61
+ ? "Your credit balance is zero. Credits pay for agent usage on every plan; purchase credits to keep running agent executions."
62
62
  : `Your balance (${formatCreditBalance(availableMicros)}) is below the warning threshold. Consider purchasing additional credits.`}
63
63
  </p>
64
64
  </div>
@@ -14,6 +14,14 @@ export interface PaymentMethodCardProps {
14
14
  readonly isPortalLoading?: boolean;
15
15
  /** Called when the user clicks "Manage payment methods". */
16
16
  readonly onManage: () => void;
17
+ /**
18
+ * Called when the user clicks "Add payment method" with none on file:
19
+ * the host opens Stripe's page to save a card without a charge. Absent,
20
+ * the card has no add action.
21
+ */
22
+ readonly onAdd?: () => void;
23
+ /** `true` while the card page is being opened. */
24
+ readonly isAdding?: boolean;
17
25
  /** Additional CSS class names. */
18
26
  readonly className?: string;
19
27
  }
@@ -61,6 +69,8 @@ export function PaymentMethodCard({
61
69
  accountStatus,
62
70
  isPortalLoading,
63
71
  onManage,
72
+ onAdd,
73
+ isAdding,
64
74
  className,
65
75
  }: PaymentMethodCardProps) {
66
76
  const isAccountActive =
@@ -113,9 +123,25 @@ export function PaymentMethodCard({
113
123
  ) : (
114
124
  <div className="stg:mt-2">
115
125
  <p className="stg:text-xs stg:text-muted-foreground">
116
- No payment method on file. A card will be saved automatically
117
- when you purchase your first credit pack.
126
+ No payment method on file. A saved card is needed to subscribe to
127
+ a plan or turn on auto-recharge; buying a credit pack also saves
128
+ one.
118
129
  </p>
130
+ {onAdd && (
131
+ <button
132
+ type="button"
133
+ disabled={!isAccountActive || isAdding}
134
+ onClick={onAdd}
135
+ className={cn(
136
+ "stg:mt-2 stg:rounded-md stg:px-2.5 stg:py-1 stg:text-xs stg:font-medium stg:transition-colors",
137
+ "stg:text-primary stg:hover:bg-accent",
138
+ "stg:disabled:pointer-events-none stg:disabled:opacity-50",
139
+ )}
140
+ aria-busy={isAdding}
141
+ >
142
+ {isAdding ? "Opening\u2026" : "Add payment method"}
143
+ </button>
144
+ )}
119
145
  </div>
120
146
  )}
121
147
  </div>
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { cn } from "@stigmer/theme";
4
+ import { getUserMessage } from "@stigmer/sdk";
5
+ import {
6
+ PeriodEstimateLineKind,
7
+ type PeriodEstimate,
8
+ type PeriodEstimateLine,
9
+ } from "@stigmer/protos/ai/stigmer/billing/subscription/v1/io_pb";
10
+ import { StatusBadge } from "../resource-workbench/components/StatusBadge.js";
11
+ import { formatCreditBalance, formatLedgerAmount } from "./format.js";
12
+ import { formatDay, formatPeriodDay } from "./plan-features.js";
13
+ import type { PlanStanding } from "./plan-state.js";
14
+
15
+ /** Props for {@link PlanCard}. */
16
+ export interface PlanCardProps {
17
+ /** The plan's display name ("Free" when there is none). */
18
+ readonly planName: string;
19
+ /** Where the organization stands on it. */
20
+ readonly standing: PlanStanding;
21
+ /** The current period's estimated invoice, when a plan is live. */
22
+ readonly estimate?: PeriodEstimate | null;
23
+ /** Why the estimate could not be read, shown in its place. */
24
+ readonly estimateError?: Error | null;
25
+ /**
26
+ * The organization is platform-managed: it is on its integrator's plan
27
+ * and has no plan, period or invoice of its own.
28
+ */
29
+ readonly managed?: boolean;
30
+ /** Additional CSS class names. */
31
+ readonly className?: string;
32
+ }
33
+
34
+ /**
35
+ * The organization's plan: its name, its standing (active, payment
36
+ * failed, or ending on a date), the period it is in, and the period's
37
+ * estimated invoice line by line, the way the invoice will read it.
38
+ */
39
+ export function PlanCard({ planName, standing, estimate, estimateError, managed, className }: PlanCardProps) {
40
+ return (
41
+ <div
42
+ className={cn(
43
+ "stg:rounded-lg stg:border stg:border-border stg:bg-card stg:px-4 stg:py-4",
44
+ className,
45
+ )}
46
+ >
47
+ <div className="stg:flex stg:items-center stg:justify-between stg:gap-3">
48
+ <h3 className="stg:text-xs stg:font-semibold stg:text-foreground">Plan</h3>
49
+ {!managed && <StandingBadge standing={standing} />}
50
+ </div>
51
+ <p className="stg:mt-2 stg:text-sm stg:font-medium stg:text-foreground">
52
+ {managed ? "Your integrator's plan" : planName}
53
+ </p>
54
+
55
+ {managed ? (
56
+ <p className="stg:mt-1 stg:text-xs stg:text-muted-foreground">
57
+ This organization is managed by its integrator and runs on the integrator&apos;s plan. It has no plan or
58
+ invoice of its own.
59
+ </p>
60
+ ) : (
61
+ <StandingDetail standing={standing} />
62
+ )}
63
+
64
+ {!managed && estimate && <EstimateLines planName={planName} estimate={estimate} />}
65
+ {!managed && !estimate && estimateError && (
66
+ <p className="stg:mt-3 stg:text-xs stg:text-destructive" role="alert">
67
+ {getUserMessage(estimateError)}
68
+ </p>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ function StandingBadge({ standing }: { standing: PlanStanding }) {
75
+ switch (standing.kind) {
76
+ case "active":
77
+ return <StatusBadge phase="ready" label="Active" />;
78
+ case "past-due":
79
+ return <StatusBadge phase="failed" label="Payment failed" />;
80
+ case "ending":
81
+ return <StatusBadge phase="degraded" label={`Ends ${formatDay(standing.endsAt)}`} />;
82
+ case "free":
83
+ return <StatusBadge phase="disabled" label="Free" />;
84
+ default: {
85
+ const exhaustive: never = standing;
86
+ return exhaustive;
87
+ }
88
+ }
89
+ }
90
+
91
+ function StandingDetail({ standing }: { standing: PlanStanding }) {
92
+ switch (standing.kind) {
93
+ case "free":
94
+ return (
95
+ <p className="stg:mt-1 stg:text-xs stg:text-muted-foreground">
96
+ Channels, sharing and unlimited members. Usage is paid from credits.
97
+ </p>
98
+ );
99
+ case "past-due":
100
+ return (
101
+ <p className="stg:mt-1 stg:text-xs stg:text-destructive" role="status">
102
+ The last period&apos;s payment failed. Stripe retries it; update the card under Payment Method. The plan stays
103
+ in force meanwhile.
104
+ </p>
105
+ );
106
+ case "ending":
107
+ return (
108
+ <p className="stg:mt-1 stg:text-xs stg:text-muted-foreground">
109
+ Canceled. The plan stays in force until {formatDay(standing.endsAt)}, then this
110
+ organization is on Free. Nothing you built is deleted.
111
+ </p>
112
+ );
113
+ case "active":
114
+ return null;
115
+ default: {
116
+ const exhaustive: never = standing;
117
+ return exhaustive;
118
+ }
119
+ }
120
+ }
121
+
122
+ function EstimateLines({ planName, estimate }: { planName: string; estimate: PeriodEstimate }) {
123
+ return (
124
+ <div className="stg:mt-4 stg:border-t stg:border-border stg:pt-3">
125
+ <div className="stg:flex stg:items-baseline stg:justify-between stg:gap-3">
126
+ <p className="stg:text-xs stg:font-medium stg:text-foreground">
127
+ Estimated invoice on {formatPeriodDay(estimate.periodEnd)}
128
+ </p>
129
+ <p className="stg:text-xs stg:text-muted-foreground">
130
+ Period {formatPeriodDay(estimate.periodStart)} – {formatPeriodDay(estimate.periodEnd)}
131
+ </p>
132
+ </div>
133
+ <dl className="stg:mt-2 stg:space-y-1">
134
+ {estimate.lines.map((line) => (
135
+ <div key={line.kind} className="stg:flex stg:justify-between stg:gap-3 stg:text-xs">
136
+ <dt className="stg:text-muted-foreground">{lineLabel(line, planName, estimate)}</dt>
137
+ <dd className="stg:tabular-nums stg:text-foreground">
138
+ {line.amountMicros < BigInt(0) ? formatLedgerAmount(line.amountMicros) : formatCreditBalance(line.amountMicros)}
139
+ </dd>
140
+ </div>
141
+ ))}
142
+ <div className="stg:flex stg:justify-between stg:gap-3 stg:border-t stg:border-border stg:pt-1 stg:text-xs stg:font-medium">
143
+ <dt className="stg:text-foreground">Total so far</dt>
144
+ <dd className="stg:tabular-nums stg:text-foreground">{formatCreditBalance(estimate.totalMicros)}</dd>
145
+ </div>
146
+ </dl>
147
+ <p className="stg:mt-2 stg:text-xs stg:text-muted-foreground">
148
+ From usage so far. Usage itself is paid from credits; the commission already paid on it counts toward the
149
+ minimum, which covers the whole period.
150
+ </p>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ function lineLabel(line: PeriodEstimateLine, planName: string, estimate: PeriodEstimate): string {
156
+ switch (line.kind) {
157
+ case PeriodEstimateLineKind.plan:
158
+ return `${planName} plan`;
159
+ case PeriodEstimateLineKind.commission_credit:
160
+ return "Commission already paid on tokens";
161
+ case PeriodEstimateLineKind.managed_organizations:
162
+ return `Managed organizations beyond those included (${estimate.managedOrganizationCount} this period)`;
163
+ case PeriodEstimateLineKind.period_estimate_line_kind_unspecified:
164
+ return "Other";
165
+ default: {
166
+ const exhaustive: never = line.kind;
167
+ return String(exhaustive);
168
+ }
169
+ }
170
+ }
171
+