@growth-operations/ui-shared 1.19.1 → 1.21.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.
@@ -59,16 +59,26 @@ export function hasTopUpBank(ent) {
59
59
  // no bank section)
60
60
  // alert — { variant, title, message } or null
61
61
  // grantExpiresAt — pass-through for the free-grant countdown
62
+ // renewsOn — "YYYY-MM-DD" the monthly pool renews on, from the
63
+ // state.credits block (null when absent)
62
64
  //
63
65
  // BAR COLORS ARE PER-BAR FILL LEVELS (Jasper 2026-10): a pool at 99% used
64
66
  // with a full bank must NOT render green just because the account is healthy
65
67
  // — each bar colors by its own level; the StatusTag keeps the account-state
66
- // colors. Thresholds mirror the backend's ~80/95 low-credit alert ladder
67
- // (common.entitlements LOW/DEPLETED banding). Comparisons are integer
68
- // cross-multiplications — no float fuzz at the 80/95/50/20 boundaries.
68
+ // colors. The pool bar colors on % CONSUMED (Andrew 2026-10: green under
69
+ // 50%, orange 50–80%, red over 80%). Comparisons are integer
70
+ // cross-multiplications — no float fuzz at the 50/80/20 boundaries.
69
71
  //
70
- // pool (DEPLETION meter, value = poolUsed of poolGrant):
71
- // success when used < 80%, warning at >= 80%, danger at >= 95%.
72
+ // THE state.credits BLOCK (2026-10 contract addition): when the payload
73
+ // carries `credits` ({ monthly: { remaining, included, renews_on },
74
+ // additional: { remaining, purchased, expires } }) it is AUTHORITATIVE for
75
+ // the two-bucket display — no deriving pool/bank from the entitlement's
76
+ // combined numbers. Absent (older backends) -> the legacy entitlement
77
+ // derivation below, unchanged.
78
+ //
79
+ // pool (DEPLETION meter, value = poolUsed of poolGrant), colored on %
80
+ // consumed per Andrew's 2026-10 feedback: success under 50% used, warning
81
+ // from 50% through 80%, danger over 80%.
72
82
  // bank (LEVEL gauge, value = bankRemaining of bankGranted):
73
83
  // success above 50% full, warning at <= 50%. NO danger variant:
74
84
  // HubSpot's ProgressBar paints the WHOLE TRACK red in danger (not just
@@ -78,8 +88,8 @@ export function hasTopUpBank(ent) {
78
88
  // danger stays on the pool bar, where near-full is the truth.
79
89
  function poolBarVariantFor(poolUsed, poolGrant) {
80
90
  if (!(poolGrant > 0)) return "success"; // no grant: nothing to deplete
81
- if (poolUsed * 100 >= 95 * poolGrant) return "danger";
82
- if (poolUsed * 100 >= 80 * poolGrant) return "warning";
91
+ if (poolUsed * 100 > 80 * poolGrant) return "danger";
92
+ if (poolUsed * 100 >= 50 * poolGrant) return "warning";
83
93
  return "success";
84
94
  }
85
95
 
@@ -88,10 +98,14 @@ function bankBarVariantFor(bankRemaining, bankGranted) {
88
98
  if (bankRemaining * 100 <= 50 * bankGranted) return "warning";
89
99
  return "success";
90
100
  }
91
- export function resolveCreditMeter(entitlement, creditMeter) {
101
+ export function resolveCreditMeter(entitlement, creditMeter, credits = null) {
92
102
  const ent = entitlement?.mode === "credits" ? entitlement : null;
93
103
  const meter = creditMeter ?? null;
94
- if (!ent && !meter) return null;
104
+ // The state.credits block stands on its own (it IS the credit data), so it
105
+ // keeps the meter alive even on a payload whose entitlement arm is missing.
106
+ const creditsBlock =
107
+ credits && (credits.monthly || credits.additional) ? credits : null;
108
+ if (!ent && !meter && !creditsBlock) return null;
95
109
 
96
110
  // Number precedence: once the entitlement arm carries the top-up fields it
97
111
  // is authoritative — the credit_meter block stays RAW pool-only under the
@@ -103,9 +117,25 @@ export function resolveCreditMeter(entitlement, creditMeter) {
103
117
  const pick = (key) =>
104
118
  entHasTopUpFields ? ent[key] ?? meter?.[key] : meter?.[key] ?? ent?.[key];
105
119
 
106
- const granted = pick("granted") ?? 0;
107
- const used = pick("used") ?? 0;
108
- const remaining = pick("remaining") ?? Math.max(granted - used, 0);
120
+ // Number source: the state.credits block when present (authoritative
121
+ // two-bucket numbers), else the entitlement/meter precedence chain.
122
+ let granted;
123
+ let used;
124
+ let remaining;
125
+ if (creditsBlock) {
126
+ const monthly = creditsBlock.monthly ?? {};
127
+ const additional = creditsBlock.additional ?? {};
128
+ const poolGrant = Math.max(0, monthly.included ?? 0);
129
+ const poolRemaining = Math.max(0, monthly.remaining ?? 0);
130
+ const bankRemaining = Math.max(0, additional.remaining ?? 0);
131
+ granted = poolGrant + bankRemaining;
132
+ remaining = poolRemaining + bankRemaining;
133
+ used = Math.max(granted - remaining, 0);
134
+ } else {
135
+ granted = pick("granted") ?? 0;
136
+ used = pick("used") ?? 0;
137
+ remaining = pick("remaining") ?? Math.max(granted - used, 0);
138
+ }
109
139
 
110
140
  const onPaidPlan = !!ent?.plan;
111
141
  const lowThreshold = ent?.low_threshold ?? 0;
@@ -125,22 +155,45 @@ export function resolveCreditMeter(entitlement, creditMeter) {
125
155
  // bank portion is bankRemaining and the rest is pool remaining; the pool
126
156
  // segment's full size is the combined denominator minus the bank.
127
157
  //
128
- // The bank section renders ONLY while the bank has credits left
129
- // (bankRemaining > 0): a zero bank — never topped up, or fully burned —
130
- // renders the plain single-pool bar, and with bankRemaining 0 the combined
131
- // numbers ARE the pool numbers (granted == poolGrant, remaining ==
132
- // poolRemaining), so nothing is lost.
158
+ // Under the state.credits block the two-bucket layout is ALWAYS in force:
159
+ // the monthly line (+ "Renews on" subline + pool bar) renders even when the
160
+ // account has never bought additional credits; the additional-credits line
161
+ // rides along only when the account has (or ever bought) a bank —
162
+ // purchased > 0 carries the true lifetime total, so a spent bank reads
163
+ // "0 of 1,000 left" instead of disappearing (bank.showAdditional).
164
+ //
165
+ // Under the legacy derivation (no credits block) the bank section renders
166
+ // ONLY while the bank has credits left (bankRemaining > 0): a zero bank —
167
+ // never topped up, or fully burned — renders the plain single-pool bar, and
168
+ // with bankRemaining 0 the combined numbers ARE the pool numbers (granted
169
+ // == poolGrant, remaining == poolRemaining), so nothing is lost.
133
170
  let bank = null;
134
- const bankRemaining = Math.max(0, ent?.top_up_bank_remaining ?? 0);
135
- if (bankRemaining > 0) {
136
- const poolGrant = Math.max(granted - bankRemaining, 0);
137
- const poolRemaining = Math.min(Math.max(remaining - bankRemaining, 0), poolGrant);
171
+ const bankRemaining = creditsBlock
172
+ ? Math.max(0, creditsBlock.additional?.remaining ?? 0)
173
+ : Math.max(0, ent?.top_up_bank_remaining ?? 0);
174
+ const bankGrantedTotal = creditsBlock
175
+ ? Math.max(0, creditsBlock.additional?.purchased ?? 0)
176
+ : Math.max(0, ent?.top_up_bank_granted ?? 0);
177
+ if (creditsBlock || bankRemaining > 0) {
178
+ const poolGrant = creditsBlock
179
+ ? Math.max(0, creditsBlock.monthly?.included ?? 0)
180
+ : Math.max(granted - bankRemaining, 0);
181
+ const poolRemaining = creditsBlock
182
+ ? Math.min(Math.max(0, creditsBlock.monthly?.remaining ?? 0), poolGrant)
183
+ : Math.min(Math.max(remaining - bankRemaining, 0), poolGrant);
138
184
  bank = {
139
185
  bankRemaining,
140
186
  // The bank's lifetime granted total — the bank bar's denominator. 0 on
141
187
  // backends that predate top_up_bank_granted (the component degrades to
142
188
  // a text-only bank line; never divide by zero, never "of 0").
143
- bankGranted: Math.max(0, ent?.top_up_bank_granted ?? 0),
189
+ bankGranted: bankGrantedTotal,
190
+ // Whether the additional-credits line renders at all. Legacy path: yes
191
+ // (we only get here with bankRemaining > 0). credits block: only when
192
+ // the account has or bought a bank — otherwise the two-bucket layout is
193
+ // just the monthly line.
194
+ showAdditional: creditsBlock
195
+ ? bankRemaining > 0 || bankGrantedTotal > 0
196
+ : true,
144
197
  poolGrant,
145
198
  poolRemaining,
146
199
  poolUsed: poolGrant - poolRemaining,
@@ -158,7 +211,7 @@ export function resolveCreditMeter(entitlement, creditMeter) {
158
211
  tagLabel = "Depleted";
159
212
  } else if (thresholdState === "top_up_failed") {
160
213
  tagVariant = "danger";
161
- tagLabel = "Top-up failed";
214
+ tagLabel = "Couldn't add credits";
162
215
  } else if (thresholdState === "limit_reached") {
163
216
  tagVariant = "warning";
164
217
  tagLabel = "Limit reached";
@@ -195,7 +248,7 @@ export function resolveCreditMeter(entitlement, creditMeter) {
195
248
  variant: "error",
196
249
  title: "You're out of credits",
197
250
  message: onPaidPlan
198
- ? "Actions are paused until your next billing period or a successful top-up."
251
+ ? "Actions are paused until your plan renews or more credits are added."
199
252
  : "Choose a plan to resume your use.",
200
253
  };
201
254
  } else if (thresholdState === "top_up_failed") {
@@ -203,14 +256,14 @@ export function resolveCreditMeter(entitlement, creditMeter) {
203
256
  variant: "error",
204
257
  title: "We couldn't add credits",
205
258
  message:
206
- "The automatic top-up charge failed — update your payment method in Billing.",
259
+ "The automatic additional-credits purchase failed — update your payment method in Billing.",
207
260
  };
208
261
  } else if (thresholdState === "limit_reached") {
209
262
  alert = {
210
263
  variant: "warning",
211
264
  title: "Billing limit reached",
212
265
  message:
213
- "Automatic top-ups are paused at your spending limit for this period. Raise your billing limit in Billing to resume them.",
266
+ "Automatic additional-credit purchases are paused at your spending limit for this period. Raise your billing limit in Billing to resume them.",
214
267
  };
215
268
  } else if (overIncluded) {
216
269
  alert = {
@@ -223,7 +276,8 @@ export function resolveCreditMeter(entitlement, creditMeter) {
223
276
  alert = {
224
277
  variant: "warning",
225
278
  title: "Running low on credits",
226
- message: "You're getting close to your limit — pick a plan to top up.",
279
+ message:
280
+ "You're getting close to your limit — pick a plan or add more credits.",
227
281
  };
228
282
  }
229
283
 
@@ -242,6 +296,9 @@ export function resolveCreditMeter(entitlement, creditMeter) {
242
296
  bankBarVariant,
243
297
  alert,
244
298
  grantExpiresAt: ent?.grant_expires_at ?? null,
299
+ // "YYYY-MM-DD" the monthly pool renews on (state.credits block only; null
300
+ // on older backends — the component just skips the subline).
301
+ renewsOn: creditsBlock ? (creditsBlock.monthly?.renews_on ?? null) : null,
245
302
  };
246
303
  }
247
304
 
@@ -282,7 +339,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
282
339
  return {
283
340
  variant: "error",
284
341
  title: "We couldn't add credits",
285
- message: `The automatic top-up charge failed. Update your payment method so your ${noun} aren't interrupted.`,
342
+ message: `The automatic additional-credits purchase failed. Update your payment method so your ${noun} aren't interrupted.`,
286
343
  cta: { kind: "portal", label: "Update payment method" },
287
344
  };
288
345
  }
@@ -291,7 +348,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
291
348
  variant: "warning",
292
349
  title: "Billing limit reached",
293
350
  message:
294
- "Automatic top-ups are paused — you've hit your spending limit for this billing period. Raise your billing limit to keep them going.",
351
+ "Automatic additional-credit purchases are paused — you've hit your spending limit for this billing period. Raise your billing limit to keep them going.",
295
352
  cta: { kind: "billing", label: "Raise your billing limit" },
296
353
  };
297
354
  }
@@ -312,7 +369,7 @@ export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } =
312
369
  variant: "error",
313
370
  title: "You're out of credits",
314
371
  message: onPaidPlan
315
- ? `Your ${noun} are paused until your next billing period or a successful top-up.`
372
+ ? `Your ${noun} are paused until your plan renews or more credits are added.`
316
373
  : `Your credits are used up. Choose a plan in Billing to keep ${label}.`,
317
374
  cta: { kind: "billing", label: "Go to Billing →" },
318
375
  };
@@ -0,0 +1,54 @@
1
+ // Pure resolver for the Home "Most used features" / "Recommended to explore"
2
+ // sections, fed by the additive `feature_usage` block on the /home payload
3
+ // (growth-ops-apps-base computes it from the credit ledger). Framework-free
4
+ // (no React) so the branch math is unit-testable; ../home/FeatureUsage.jsx
5
+ // stays thin.
6
+ //
7
+ // Degrades gracefully by contract: the block is ABSENT on older backends,
8
+ // on legacy (Anvil) installs, and on apps with no feature registry — every
9
+ // one of those resolves to null (render nothing), never an empty box.
10
+
11
+ // The wire field is `credits_or_events`: the registry anticipates
12
+ // event-denominated features, so a most_used row MAY carry a `unit` hint
13
+ // ("credits" | "events"; singular spellings accepted). Absent -> "credits",
14
+ // which is what every current backend emits.
15
+ const normalizeUnit = (u) =>
16
+ typeof u === "string" && u.toLowerCase().startsWith("event") ? "events" : "credits";
17
+
18
+ // Resolve the feature_usage block into render-ready shape:
19
+ // { mostUsed: [{ key, name, count, unit }],
20
+ // notInUse: [{ key, name, docsUrl, rationale }] }
21
+ // Returns null when the block is absent/malformed or BOTH lists are empty
22
+ // (an install using every detectable feature has nothing to show here).
23
+ export function resolveFeatureUsage(featureUsage) {
24
+ if (!featureUsage || typeof featureUsage !== "object") return null;
25
+
26
+ const mostUsed = (Array.isArray(featureUsage.most_used) ? featureUsage.most_used : [])
27
+ .filter((f) => f && f.key != null && f.name)
28
+ .map((f) => ({
29
+ key: f.key,
30
+ name: f.name,
31
+ count: typeof f.credits_or_events === "number" ? f.credits_or_events : 0,
32
+ unit: normalizeUnit(f.unit),
33
+ }));
34
+
35
+ const notInUse = (Array.isArray(featureUsage.not_in_use) ? featureUsage.not_in_use : [])
36
+ .filter((f) => f && f.key != null && f.name)
37
+ .map((f) => ({
38
+ key: f.key,
39
+ name: f.name,
40
+ docsUrl: typeof f.docs_url === "string" && f.docs_url ? f.docs_url : null,
41
+ rationale: typeof f.rationale === "string" ? f.rationale : "",
42
+ }));
43
+
44
+ if (mostUsed.length === 0 && notInUse.length === 0) return null;
45
+ return { mostUsed, notInUse };
46
+ }
47
+
48
+ // The block lives at payload.state.feature_usage on current backends. The
49
+ // bare `payload.feature_usage` fallback keeps the component working if a
50
+ // host ever passes the inner state block instead of the whole payload.
51
+ export function featureUsageOf(state) {
52
+ if (!state || typeof state !== "object") return null;
53
+ return resolveFeatureUsage(state.state?.feature_usage ?? state.feature_usage);
54
+ }
@@ -0,0 +1,146 @@
1
+ // Pure resolvers for the Home cockpit blocks added for Andrew's 2026-10
2
+ // App Home feedback: the trial banner (three states), the plan-fit signal,
3
+ // and the period-over-period credit comparison. Framework-free (no React) so
4
+ // the branch math is unit-testable; the components in ../home stay thin.
5
+ //
6
+ // Everything here degrades gracefully: every block is additive on the
7
+ // /v1/home payload, and a resolver returns null (render nothing) whenever
8
+ // the fields it needs are absent.
9
+ import { daysUntil } from "./format";
10
+
11
+ // --- Trial banner ----------------------------------------------------------
12
+
13
+ // Rides under every trial banner (Andrew's exact copy).
14
+ export const TRIAL_BANNER_FOOTER = "Need help? info@growthoperationsco.com";
15
+
16
+ // Resolve the Home trial banner from the credits entitlement arm.
17
+ // Returns null (render nothing) for non-credits modes, paid plans (the trial
18
+ // is over), pre-install, or a backend that carries no grant clock. Otherwise
19
+ // { variant, title, body, footer } — copy verbatim from Andrew, with the app
20
+ // display name templated in.
21
+ //
22
+ // State selection (first match wins):
23
+ // out of credits — the free grant is spent (depleted / remaining <= 0).
24
+ // Beats the clock: a burned-through grant is "out of
25
+ // credits" even with days left.
26
+ // trial ended — the grant's 30-day clock ran out with credits left.
27
+ // trial active — the clock is still running: "[XX] trial days remaining".
28
+ export function resolveTrialBanner(entitlement, { appName } = {}) {
29
+ const ent = entitlement;
30
+ if (!ent || ent.mode !== "credits") return null;
31
+ if (ent.plan) return null; // a paid plan is past the trial
32
+ if (ent.status === "not_installed") return null;
33
+ const name = appName ?? "this app";
34
+
35
+ const granted = ent.granted ?? 0;
36
+ const used = ent.used ?? 0;
37
+ const remaining = ent.remaining ?? Math.max(granted - used, 0);
38
+ const daysLeft =
39
+ ent.grant_expires_at != null ? daysUntil(ent.grant_expires_at) : null;
40
+
41
+ if (ent.depleted === true || remaining <= 0) {
42
+ return {
43
+ variant: "error",
44
+ title: "You're out of credits",
45
+ body: `You have used ${used}/${granted} free credits. Select one of the plans below to continue use of ${name}.`,
46
+ footer: TRIAL_BANNER_FOOTER,
47
+ };
48
+ }
49
+ if (daysLeft != null && daysLeft <= 0) {
50
+ return {
51
+ variant: "warning",
52
+ title: "Your trial has ended",
53
+ body: `Your free trial has ended. Select a plan to continue use of ${name}.`,
54
+ footer: TRIAL_BANNER_FOOTER,
55
+ };
56
+ }
57
+ if (daysLeft != null) {
58
+ return {
59
+ variant: "info",
60
+ title: `${daysLeft} trial days remaining`,
61
+ body: `You have ${daysLeft} days remaining in your trial period. Select a plan to continue use of ${name}.`,
62
+ footer: TRIAL_BANNER_FOOTER,
63
+ };
64
+ }
65
+ // No grant clock on record (a backend that predates grant_expires_at) —
66
+ // nothing to count down, so no banner.
67
+ return null;
68
+ }
69
+
70
+ // --- Plan fit ---------------------------------------------------------------
71
+
72
+ const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
73
+
74
+ // "$40/mo" from a dollar number; whole amounts drop the decimals.
75
+ const fmtSavings = (usd) =>
76
+ `$${Number.isInteger(usd) ? usd : usd.toFixed(2)}/mo`;
77
+
78
+ // Resolve the plan_fit block. Returns:
79
+ // null — block absent/unknown verdict (render nothing)
80
+ // { kind: "right_plan" } — reinforces the healthy hero
81
+ // { kind: "upgrade_recommended", text } — the savings banner body, Andrew's
82
+ // copy with the tier/savings/pack count templated in; degrades to a
83
+ // plainer sentence when the backend can't price the savings yet.
84
+ export function resolvePlanFit(planFit) {
85
+ if (!planFit || typeof planFit !== "object") return null;
86
+
87
+ if (planFit.verdict === "right_plan") {
88
+ return { kind: "right_plan" };
89
+ }
90
+
91
+ if (planFit.verdict === "upgrade_recommended") {
92
+ const tier =
93
+ typeof planFit.target_tier === "string" && planFit.target_tier
94
+ ? capitalize(planFit.target_tier)
95
+ : null;
96
+ const savings =
97
+ typeof planFit.monthly_savings_usd === "number"
98
+ ? fmtSavings(planFit.monthly_savings_usd)
99
+ : null;
100
+ const packs = planFit.topup_packs_this_period ?? 0;
101
+ const packsText = `${packs} additional credit pack${packs === 1 ? "" : "s"}`;
102
+
103
+ let text;
104
+ if (savings && tier) {
105
+ text = `You could save ${savings} by moving to ${tier} — you bought ${packsText} this period.`;
106
+ } else if (tier) {
107
+ text = `You could save by moving to ${tier} — you bought ${packsText} this period.`;
108
+ } else {
109
+ text = `A higher plan may cost you less — you bought ${packsText} this period.`;
110
+ }
111
+ return { kind: "upgrade_recommended", text };
112
+ }
113
+
114
+ return null;
115
+ }
116
+
117
+ // --- Period comparison ------------------------------------------------------
118
+
119
+ // Resolve the period_comparison block (null until the install has one full
120
+ // closed period) into render-ready shape:
121
+ // { last, current, maxTotal } where each period is
122
+ // { label, total, byAction: [{ action, credits }] } and maxTotal is the
123
+ // shared bar denominator (so the two bars are visually comparable; >= 1 so
124
+ // a pair of zero periods never divides by zero).
125
+ // Returns null when the block or either period is missing.
126
+ export function resolvePeriodComparison(periodComparison) {
127
+ if (!periodComparison || typeof periodComparison !== "object") return null;
128
+ const norm = (p) => {
129
+ if (!p || typeof p !== "object") return null;
130
+ return {
131
+ label: p.label ?? "",
132
+ total: p.total_credits ?? 0,
133
+ byAction: Array.isArray(p.by_action)
134
+ ? p.by_action.filter((a) => a && a.action != null)
135
+ : [],
136
+ };
137
+ };
138
+ const last = norm(periodComparison.last_period);
139
+ const current = norm(periodComparison.current_period);
140
+ if (!last || !current) return null;
141
+ return {
142
+ last,
143
+ current,
144
+ maxTotal: Math.max(last.total, current.total, 1),
145
+ };
146
+ }
@@ -28,24 +28,18 @@ export async function getTopUps(context, { appKey, portalId } = {}) {
28
28
  return callAppApi(context, billingPath(appKey, "top-ups", portalId), "GET");
29
29
  }
30
30
 
31
- // GET {billingBaseUrl}/v1/billing/invoices?token=... ->
32
- // { invoices: [{ id, date, description, amount_cents, currency, status,
33
- // hosted_invoice_url, kind }] } (newest first, capped at 24)
34
- //
35
- // The billing service (growth-ops-apps-app), NOT base — see the header note.
36
- // Auth is the signed billing-action `token` query param
37
- // (common.billing.action_token): the endpoint is read-only and accepts any
38
- // action; BillingTab passes the (interval-refreshed)
39
- // state.billing_action_tokens.portal. The claim carries app_key + portal_id,
40
- // so the response is scoped to THIS portal's Stripe customer — never a
41
- // stranger's invoices.
42
- //
43
- // Throws AppApiError on failure, with the FastAPI {"detail": ...} string as
44
- // the message — a 404 means the billing service predates the invoices route
45
- // (the caller hides the section rather than erroring forever).
46
- export async function getInvoices({ billingBaseUrl, token } = {}) {
47
- const url = `${billingBaseUrl}/v1/billing/invoices?token=${encodeURIComponent(token)}`;
48
- const response = await hubspot.fetch(url, { timeout: 10000, method: "GET" });
31
+ // Shared fetch for the BILLING service's token-auth routes
32
+ // (/v1/billing/*) — bare hubspot.fetch with the signed action token as a
33
+ // query param, no custom headers (the platform rejects them), body as a plain
34
+ // object (hubspot.fetch serializes it; see callAppApi's header note).
35
+ // Throws AppApiError on failure with the FastAPI {"detail": ...} string as
36
+ // the message.
37
+ async function billingFetch(url, { method = "GET", body } = {}) {
38
+ const response = await hubspot.fetch(url, {
39
+ timeout: 10000,
40
+ method,
41
+ ...(body && { body }),
42
+ });
49
43
  if (!response.ok) {
50
44
  const errorText = await response.text();
51
45
  // FastAPI errors come back as {"detail": "..."} — surface just the detail
@@ -64,6 +58,49 @@ export async function getInvoices({ billingBaseUrl, token } = {}) {
64
58
  return response.json();
65
59
  }
66
60
 
61
+ // GET {billingBaseUrl}/v1/billing/invoices?token=... ->
62
+ // { invoices: [{ id, date, description, amount_cents, currency, status,
63
+ // hosted_invoice_url, kind }] } (newest first, capped at 24)
64
+ //
65
+ // The billing service (growth-ops-apps-app), NOT base — see the header note.
66
+ // Auth is the signed billing-action `token` query param
67
+ // (common.billing.action_token): the endpoint is read-only and accepts any
68
+ // action; BillingTab passes the (interval-refreshed)
69
+ // state.billing_action_tokens.portal. The claim carries app_key + portal_id,
70
+ // so the response is scoped to THIS portal's Stripe customer — never a
71
+ // stranger's invoices.
72
+ //
73
+ // A 404 means the billing service predates the invoices route (the caller
74
+ // hides the section rather than erroring forever).
75
+ export async function getInvoices({ billingBaseUrl, token } = {}) {
76
+ const url = `${billingBaseUrl}/v1/billing/invoices?token=${encodeURIComponent(token)}`;
77
+ return billingFetch(url, { method: "GET" });
78
+ }
79
+
80
+ // POST /v1/hubspot/app_pages/{app}/billing/auto-provision, body
81
+ // { enabled: bool } — set the account's auto-provision flag (REPLACES the old
82
+ // billing-limit concept): enabled buys another pack of additional credits
83
+ // when the monthly credits run out; disabled means no purchase and actions
84
+ // start failing the entitlement gate.
85
+ //
86
+ // This route lives on the BASE service (same app_pages family as
87
+ // billing/limit), NOT the GOAA billing service — there is no
88
+ // /v1/billing/auto-provision there. Same callAppApi + ?token= posture as
89
+ // setBillingLimit below: verify_hubspot plus the billing action token (the
90
+ // claim scopes the change to THIS portal's account); BillingTab passes the
91
+ // interval-refreshed state.billing_action_tokens.portal.
92
+ export async function setAutoProvision(
93
+ context,
94
+ { appKey, portalId, enabled, token } = {}
95
+ ) {
96
+ return callAppApi(
97
+ context,
98
+ billingPath(appKey, "auto-provision", portalId, { token }),
99
+ "POST",
100
+ { enabled: !!enabled }
101
+ );
102
+ }
103
+
67
104
  // POST /v1/hubspot/app_pages/{app}/billing/limit, body { limit_cents: int|null }
68
105
  // — set (or clear, with null) the per-period auto top-up spend cap. Body is a
69
106
  // plain object, never JSON.stringify'd (the hubspot.fetch rule callAppApi
package/src/sdk/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // App backend (context.variables.BASE_URL) via hubspot.fetch.
5
5
  export { callAppApi, AppApiError } from "./app/base";
6
6
  export { getAlerts, dismissAlert } from "./app/alerts";
7
- export { getTopUps, getInvoices, setBillingLimit } from "./app/topUps";
7
+ export { getTopUps, getInvoices, setBillingLimit, setAutoProvision } from "./app/topUps";
8
8
  export { getSettings, updateSettings, makeSettingsApi } from "./app/settings";
9
9
  export {
10
10
  strToBase64,
@@ -27,6 +27,7 @@ import {
27
27
  } from "@hubspot/ui-extensions";
28
28
 
29
29
  import { useStrictModeEffect } from "../lib/useStrictModeEffect";
30
+ import { CreditMeter } from "../home/CreditMeter";
30
31
 
31
32
  const DEFAULT_BASE_PATH = "/api/v1/usage";
32
33
 
@@ -34,25 +35,39 @@ const WINDOW_OPTIONS = [
34
35
  { label: "Last 7 days", value: "7" },
35
36
  { label: "Last 30 days", value: "30" },
36
37
  { label: "Last 90 days", value: "90" },
38
+ { label: "Last 180 days", value: "180" },
39
+ { label: "Last 365 days", value: "365" },
37
40
  ];
38
41
 
39
42
  // context: extension context. basePath/portalId: target the right backend
40
- // (base-hosted apps pass both). appName: chart copy.
43
+ // (base-hosted apps pass both). appName: chart copy. state: the /v1/home
44
+ // payload (optional) — when present, the credit meter renders at the TOP of
45
+ // the tab (Andrew's 2026-10 feedback: the meter moved off Home); without it
46
+ // the tab is chart-only, same as before.
41
47
  export function UsageTab({
42
48
  context,
43
49
  basePath = DEFAULT_BASE_PATH,
44
50
  portalId,
45
51
  appName = "this app",
52
+ state = null,
46
53
  }) {
47
54
  const [days, setDays] = useState("30");
48
55
 
49
56
  return (
50
57
  <Flex direction="column" gap="medium">
58
+ {state && (
59
+ <CreditMeter
60
+ entitlement={state.entitlement}
61
+ creditMeter={state.credit_meter}
62
+ credits={state.credits}
63
+ />
64
+ )}
65
+
51
66
  <Flex direction="row" justify="between" align="end">
52
67
  <Heading>Usage</Heading>
53
68
  <Select
54
- label="Window"
55
- name="window"
69
+ label="Time period"
70
+ name="time-period"
56
71
  options={WINDOW_OPTIONS}
57
72
  value={days}
58
73
  onChange={setDays}
@@ -131,7 +146,7 @@ function UsageContent({ context, basePath, portalId, appName, days }) {
131
146
  <Flex direction="column" gap="medium">
132
147
  <Tile>
133
148
  <Statistics>
134
- <StatisticsItem label="Used (window)" number={usedInWindow} />
149
+ <StatisticsItem label={`Used (last ${days} days)`} number={usedInWindow} />
135
150
  {hasBalance && <StatisticsItem label="Remaining" number={remaining} />}
136
151
  {hasBalance && <StatisticsItem label="Granted" number={granted} />}
137
152
  </Statistics>