@growth-operations/ui-shared 1.18.0 → 1.19.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.18.0",
3
+ "version": "1.19.1",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -8,6 +8,7 @@ import {
8
8
  Button,
9
9
  Alert,
10
10
  Input,
11
+ Link,
11
12
  LoadingSpinner,
12
13
  Table,
13
14
  TableHead,
@@ -22,7 +23,7 @@ import { useStrictModeEffect } from "../lib/useStrictModeEffect";
22
23
  import { CreditMeter } from "../home/CreditMeter";
23
24
  import { PlanGrid } from "./PlanGrid";
24
25
  import { refreshBillingActionTokens } from "../sdk/billing";
25
- import { getTopUps, setBillingLimit } from "../sdk/app/topUps";
26
+ import { getInvoices, getTopUps, setBillingLimit } from "../sdk/app/topUps";
26
27
 
27
28
  // Billing action tokens (see common.billing.action_token) are signed with a
28
29
  // 5-minute TTL — short enough that a customer who opens the tab and comes
@@ -477,6 +478,124 @@ function TopUpHistory({ context, appKey }) {
477
478
  );
478
479
  }
479
480
 
481
+ // Invoice status -> label (Stripe's vocabulary: paid / open / void /
482
+ // uncollectible; drafts and $0 voids are filtered server-side).
483
+ const INVOICE_STATUS_LABELS = {
484
+ paid: "Paid",
485
+ open: "Open",
486
+ void: "Void",
487
+ uncollectible: "Uncollectible",
488
+ };
489
+
490
+ // The invoice-history table itself — pure rendering (exported for tests);
491
+ // fetch/loading/error live in InvoiceHistory below.
492
+ export function InvoiceHistoryTable({ invoices }) {
493
+ return (
494
+ <Table>
495
+ <TableHead>
496
+ <TableRow>
497
+ <TableHeader>Date</TableHeader>
498
+ <TableHeader>Description</TableHeader>
499
+ <TableHeader>Amount</TableHeader>
500
+ <TableHeader>Status</TableHeader>
501
+ <TableHeader>Receipt</TableHeader>
502
+ </TableRow>
503
+ </TableHead>
504
+ <TableBody>
505
+ {invoices.map((invoice, i) => (
506
+ <TableRow key={`${invoice.id ?? invoice.date}-${i}`}>
507
+ <TableCell>{fmtDate(invoice.date)}</TableCell>
508
+ <TableCell>{invoice.description}</TableCell>
509
+ <TableCell>{fmtMoney(invoice.amount_cents, invoice.currency)}</TableCell>
510
+ <TableCell>
511
+ {INVOICE_STATUS_LABELS[invoice.status] ?? invoice.status ?? "—"}
512
+ </TableCell>
513
+ <TableCell>
514
+ {invoice.hosted_invoice_url ? (
515
+ <Link href={{ url: invoice.hosted_invoice_url, external: true }}>
516
+ Receipt
517
+ </Link>
518
+ ) : (
519
+ "—"
520
+ )}
521
+ </TableCell>
522
+ </TableRow>
523
+ ))}
524
+ </TableBody>
525
+ </Table>
526
+ );
527
+ }
528
+
529
+ // "Invoice history" — every Stripe invoice on the portal's customer
530
+ // (subscription renewals, one-off charges, top-up bands — including VOIDED
531
+ // top-ups, which tell the decline story), from the billing service's
532
+ // GET /v1/billing/invoices (newest first, capped at 24). Loading, error, and
533
+ // empty states mirror TopUpHistory.
534
+ //
535
+ // Auth is the billing action token (the claim scopes the response to THIS
536
+ // portal's Stripe customer). BillingTab re-mints the token set every 3
537
+ // minutes; depending on the token means the list also quietly refreshes on
538
+ // that cadence while the tab stays open — a top-up that completes mid-session
539
+ // shows up without a reload.
540
+ //
541
+ // Two "render nothing" degradations: no billing_base_url / token yet (an
542
+ // older backend that doesn't mint them — same "hidden until token exists"
543
+ // posture as the restart link), and a 404 from a billing service that
544
+ // predates the invoices route (hide the section instead of erroring forever
545
+ // until the service's next deploy).
546
+ function InvoiceHistory({ billingBaseUrl, billingActionToken }) {
547
+ const [status, setStatus] = useState("loading"); // loading | ready | error | unsupported
548
+ const [invoices, setInvoices] = useState([]);
549
+
550
+ useStrictModeEffect(
551
+ async ({ mounted }) => {
552
+ try {
553
+ const res = await getInvoices({
554
+ billingBaseUrl,
555
+ token: billingActionToken,
556
+ });
557
+ if (mounted.current) {
558
+ setInvoices(res?.invoices ?? []);
559
+ setStatus("ready");
560
+ }
561
+ } catch (e) {
562
+ if (mounted.current) {
563
+ setStatus(e?.statusCode === 404 ? "unsupported" : "error");
564
+ }
565
+ }
566
+ },
567
+ [billingBaseUrl, billingActionToken]
568
+ );
569
+
570
+ if (!billingBaseUrl || !billingActionToken || status === "unsupported") {
571
+ return null;
572
+ }
573
+ if (status === "loading") {
574
+ return <LoadingSpinner showLabel label="Loading invoices…" />;
575
+ }
576
+ if (status === "error") {
577
+ return (
578
+ <Alert title="Couldn't load invoice history" variant="warning">
579
+ <Text>Refresh the page to try again.</Text>
580
+ </Alert>
581
+ );
582
+ }
583
+
584
+ return (
585
+ <Flex direction="column" gap="small">
586
+ <Heading>Invoice history</Heading>
587
+ {invoices.length === 0 ? (
588
+ <Text format={{ fontStyle: "italic" }}>
589
+ No invoices yet. Subscription renewals, one-off charges, and credit
590
+ top-ups are listed here.
591
+ </Text>
592
+ ) : (
593
+ <InvoiceHistoryTable invoices={invoices} />
594
+ )}
595
+ </Flex>
596
+ );
597
+ }
598
+
480
599
  // "Billing limit" — the per-period spend cap on automatic top-ups. Shows the
481
600
  // current limit (when the entitlement arm carries billing_limit_cents), sets
482
601
  // a new one (USD input -> cents), or clears it, via the base service's
@@ -699,6 +818,14 @@ export function CreditsBilling({ context, state, appKey, openIframe = null }) {
699
818
  {state?.entitlement?.billing_model === "topup" && (
700
819
  <>
701
820
  <TopUpHistory context={context} appKey={appKey} />
821
+ {/* Directly below Top-up history (Andrew's ask). `state` here is
822
+ BillingTab's interval-refreshed copy, so the token is re-minted
823
+ every 3 minutes; InvoiceHistory hides itself when the backend
824
+ or billing service predates the pieces it needs. */}
825
+ <InvoiceHistory
826
+ billingBaseUrl={base}
827
+ billingActionToken={state?.billing_action_tokens?.portal ?? null}
828
+ />
702
829
  <BillingLimitControl
703
830
  context={context}
704
831
  appKey={appKey}
@@ -17,19 +17,25 @@ import { resolveCreditMeter } from "../lib/creditDisplay";
17
17
  // billing model) gets TWO bars, one per bucket, and NO combined headline (the
18
18
  // combined pool+bank denominator read as one big confusing allowance):
19
19
  // 1. Monthly plan credits — a DEPLETION meter (value = pool used of the
20
- // period grant), color-coded by the account's depletion state: success
21
- // when healthy, warning at/below the low threshold, danger when
22
- // depleted (view.barVariant).
20
+ // period grant), colored by the POOL'S OWN fill level: success under
21
+ // 80% used, warning at >=80%, danger at >=95% (view.poolBarVariant —
22
+ // mirrors the ~80/95 low-credit alert ladder).
23
23
  // 2. Additional credits — a LEVEL gauge (value = bank remaining of the
24
- // bank's granted total), always success: the bank is cumulative and
25
- // never expires, so a used-of-granted bar would drift to
26
- // permanently-near-full over multiple purchases; the remaining level
27
- // refills on purchase and drains on spend.
28
- // Hosts whose backend predates top_up_bank_granted degrade gracefully: the
29
- // bank renders as a text-only line (no bar, never "of 0"). An account with
30
- // NO bank credits left (never topped up, or the bank is spent) renders
31
- // exactly the legacy view — the "{remaining} of {granted} credits left"
32
- // headline plus the single color-coded bar.
24
+ // bank's granted total: the bank is cumulative and never expires, so a
25
+ // used-of-granted bar would drift to permanently-near-full over multiple
26
+ // purchases; the remaining level refills on purchase and drains on
27
+ // spend), colored by ITS OWN level: success above 50% full, warning at
28
+ // <=50% (view.bankBarVariant). NO danger on this bar — HubSpot's
29
+ // ProgressBar paints the whole track red in danger, so a low gauge
30
+ // would read as FULL; at <=20% a warning "Running low" StatusTag rides
31
+ // the line instead (bank.bankLow).
32
+ // Each bar colors by its own fill, NOT the account's threshold state — the
33
+ // StatusTag keeps the account-state colors. Hosts whose backend predates
34
+ // top_up_bank_granted degrade gracefully: the bank renders as a text-only
35
+ // line (no bar, never "of 0"). An account with NO bank credits left (never
36
+ // topped up, or the bank is spent) renders exactly the legacy view — the
37
+ // "{remaining} of {granted} credits left" headline plus the single
38
+ // fill-colored bar.
33
39
  //
34
40
  // Props:
35
41
  // entitlement — the /v1/home entitlement union (uses the credits arm).
@@ -38,7 +44,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
38
44
  const view = resolveCreditMeter(entitlement, creditMeter);
39
45
  if (!view) return null;
40
46
 
41
- const { granted, used, remaining, bank, tagVariant, tagLabel, barVariant, alert } = view;
47
+ const { granted, used, remaining, bank, tagVariant, tagLabel, poolBarVariant, bankBarVariant, alert } = view;
42
48
  const grantDaysLeft = view.grantExpiresAt ? daysUntil(view.grantExpiresAt) : null;
43
49
 
44
50
  return (
@@ -62,23 +68,28 @@ export function CreditMeter({ entitlement, creditMeter }) {
62
68
  value={bank.poolUsed}
63
69
  maxValue={bank.poolGrant > 0 ? bank.poolGrant : 1}
64
70
  showPercentage={true}
65
- variant={barVariant}
71
+ variant={poolBarVariant}
66
72
  />
67
73
  </Flex>
68
74
  {bank.bankGranted > 0 ? (
69
75
  // Bank — level gauge over the granted total (refills on
70
76
  // purchase, drains on spend; never expires).
71
77
  <Flex direction="column" gap="extra-small">
72
- <Text>
73
- Additional credits (never expire): {bank.bankRemaining} of{" "}
74
- {bank.bankGranted} left
75
- </Text>
78
+ <Flex direction="row" gap="extra-small" align="center">
79
+ <Text>
80
+ Additional credits (never expire): {bank.bankRemaining} of{" "}
81
+ {bank.bankGranted} left
82
+ </Text>
83
+ {bank.bankLow && (
84
+ <StatusTag variant="warning">Running low</StatusTag>
85
+ )}
86
+ </Flex>
76
87
  <ProgressBar
77
88
  title={`${bank.bankRemaining} of ${bank.bankGranted} left`}
78
89
  value={bank.bankRemaining}
79
90
  maxValue={bank.bankGranted > 0 ? bank.bankGranted : 1}
80
91
  showPercentage={true}
81
- variant="success"
92
+ variant={bankBarVariant}
82
93
  />
83
94
  </Flex>
84
95
  ) : (
@@ -104,7 +115,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
104
115
  value={used}
105
116
  maxValue={granted > 0 ? granted : 1}
106
117
  showPercentage={true}
107
- variant={barVariant}
118
+ variant={poolBarVariant}
108
119
  />
109
120
  </>
110
121
  )}
package/src/index.js CHANGED
@@ -72,6 +72,7 @@ export {
72
72
  getAlerts,
73
73
  dismissAlert,
74
74
  getTopUps,
75
+ getInvoices,
75
76
  setBillingLimit,
76
77
  getSettings,
77
78
  updateSettings,
@@ -51,15 +51,43 @@ export function hasTopUpBank(ent) {
51
51
  // thresholdState — the arm's threshold_state, or null on a backend
52
52
  // that predates it
53
53
  // depleted/overIncluded/low — mutually-exclusive urgency flags
54
- // tagVariant/tagLabel — StatusTag rendering
55
- // barVariant — ProgressBar sentiment for the pool/single bar:
56
- // the tag's depletion mapping minus "info"
57
- // (ProgressBar only takes success/warning/danger;
58
- // top_up_pending is calm/positive, so success).
59
- // The bank bar is always "success" (a never-expiring
60
- // balance has no negative direction).
54
+ // tagVariant/tagLabel — StatusTag rendering (ACCOUNT state — unchanged)
55
+ // poolBarVariant — ProgressBar sentiment for the pool bar (and the
56
+ // no-bank legacy single bar), colored by the POOL'S
57
+ // OWN fill level, not the account state
58
+ // bankBarVariant — same for the bank level gauge (null when there is
59
+ // no bank section)
61
60
  // alert — { variant, title, message } or null
62
61
  // grantExpiresAt — pass-through for the free-grant countdown
62
+ //
63
+ // BAR COLORS ARE PER-BAR FILL LEVELS (Jasper 2026-10): a pool at 99% used
64
+ // with a full bank must NOT render green just because the account is healthy
65
+ // — 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.
69
+ //
70
+ // pool (DEPLETION meter, value = poolUsed of poolGrant):
71
+ // success when used < 80%, warning at >= 80%, danger at >= 95%.
72
+ // bank (LEVEL gauge, value = bankRemaining of bankGranted):
73
+ // success above 50% full, warning at <= 50%. NO danger variant:
74
+ // HubSpot's ProgressBar paints the WHOLE TRACK red in danger (not just
75
+ // the filled portion), so a low level gauge would read as a full bar
76
+ // (caught in Jasper's 2026-10-04 screenshot review). Urgency at <= 20%
77
+ // rides the "Running low" tag (bank.bankLow) instead of the bar color.
78
+ // danger stays on the pool bar, where near-full is the truth.
79
+ function poolBarVariantFor(poolUsed, poolGrant) {
80
+ 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";
83
+ return "success";
84
+ }
85
+
86
+ function bankBarVariantFor(bankRemaining, bankGranted) {
87
+ if (!(bankGranted > 0)) return "success"; // degraded text-only line renders no bar
88
+ if (bankRemaining * 100 <= 50 * bankGranted) return "warning";
89
+ return "success";
90
+ }
63
91
  export function resolveCreditMeter(entitlement, creditMeter) {
64
92
  const ent = entitlement?.mode === "credits" ? entitlement : null;
65
93
  const meter = creditMeter ?? null;
@@ -117,6 +145,10 @@ export function resolveCreditMeter(entitlement, creditMeter) {
117
145
  poolRemaining,
118
146
  poolUsed: poolGrant - poolRemaining,
119
147
  };
148
+ // "Running low" tag at <= 20% of the granted total (the bar itself caps
149
+ // at warning — see the header block). False when the total is unknown.
150
+ bank.bankLow =
151
+ bank.bankGranted > 0 && bankRemaining * 100 <= 20 * bank.bankGranted;
120
152
  }
121
153
 
122
154
  let tagVariant;
@@ -144,12 +176,16 @@ export function resolveCreditMeter(entitlement, creditMeter) {
144
176
  tagLabel = "Healthy";
145
177
  }
146
178
 
147
- // ProgressBar sentiment for the pool bar (and the no-bank single bar):
148
- // color-coded by depletion — success when healthy, warning at/below the
149
- // low threshold (and for the other warning-level states), danger when
150
- // depleted/top-up-failed. ProgressBar has no "info" variant, so the calm
151
- // top_up_pending state renders success.
152
- const barVariant = tagVariant === "info" ? "success" : tagVariant;
179
+ // ProgressBar sentiments — each bar colored by ITS OWN fill level (see the
180
+ // header block). The pool bar (and the no-bank legacy single bar) reads the
181
+ // pool's depletion ratio; the bank bar reads the bank's remaining level.
182
+ const poolBarVariant = poolBarVariantFor(
183
+ bank ? bank.poolUsed : used,
184
+ bank ? bank.poolGrant : granted
185
+ );
186
+ const bankBarVariant = bank
187
+ ? bankBarVariantFor(bank.bankRemaining, bank.bankGranted)
188
+ : null;
153
189
 
154
190
  // The inline meter alert mirrors the banner's messaging at the point of
155
191
  // glance. Legacy branches keep their pre-top-up copy byte-for-byte.
@@ -202,7 +238,8 @@ export function resolveCreditMeter(entitlement, creditMeter) {
202
238
  low,
203
239
  tagVariant,
204
240
  tagLabel,
205
- barVariant,
241
+ poolBarVariant,
242
+ bankBarVariant,
206
243
  alert,
207
244
  grantExpiresAt: ent?.grant_expires_at ?? null,
208
245
  };
@@ -4,7 +4,13 @@
4
4
  // headers). Base-hosted credit apps reach base at context.variables.BASE_URL;
5
5
  // `portalId` rides as a query param — the same pattern as the base-hosted
6
6
  // alerts routes (see ./alerts).
7
- import { callAppApi } from "./base";
7
+ //
8
+ // EXCEPTION: getInvoices below hits the BILLING service
9
+ // (growth-ops-apps-app at state.billing_base_url), not base — it holds the
10
+ // Stripe keys and serves every app's billing. Token-auth bare hubspot.fetch,
11
+ // same as refreshBillingActionTokens (../billing).
12
+ import { hubspot } from "@hubspot/ui-extensions";
13
+ import { callAppApi, AppApiError } from "./base";
8
14
 
9
15
  function billingPath(appKey, suffix, portalId, { token } = {}) {
10
16
  const path = `/v1/hubspot/app_pages/${encodeURIComponent(appKey)}/billing/${suffix}`;
@@ -22,6 +28,42 @@ export async function getTopUps(context, { appKey, portalId } = {}) {
22
28
  return callAppApi(context, billingPath(appKey, "top-ups", portalId), "GET");
23
29
  }
24
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" });
49
+ if (!response.ok) {
50
+ const errorText = await response.text();
51
+ // FastAPI errors come back as {"detail": "..."} — surface just the detail
52
+ // string (same convention as callAppApi).
53
+ let message = errorText;
54
+ try {
55
+ const parsed = JSON.parse(errorText);
56
+ if (parsed && typeof parsed.detail === "string") {
57
+ message = parsed.detail;
58
+ }
59
+ } catch {
60
+ // body wasn't JSON; keep the raw text
61
+ }
62
+ throw new AppApiError(message, response.status, errorText);
63
+ }
64
+ return response.json();
65
+ }
66
+
25
67
  // POST /v1/hubspot/app_pages/{app}/billing/limit, body { limit_cents: int|null }
26
68
  // — set (or clear, with null) the per-period auto top-up spend cap. Body is a
27
69
  // 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, setBillingLimit } from "./app/topUps";
7
+ export { getTopUps, getInvoices, setBillingLimit } from "./app/topUps";
8
8
  export { getSettings, updateSettings, makeSettingsApi } from "./app/settings";
9
9
  export {
10
10
  strToBase64,