@growth-operations/ui-shared 1.18.0 → 1.19.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.
- package/package.json +1 -1
- package/src/billing/BillingTab.jsx +128 -1
- package/src/home/CreditMeter.jsx +19 -16
- package/src/index.js +1 -0
- package/src/lib/creditDisplay.js +45 -14
- package/src/sdk/app/topUps.js +43 -1
- package/src/sdk/index.js +1 -1
package/package.json
CHANGED
|
@@ -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}
|
package/src/home/CreditMeter.jsx
CHANGED
|
@@ -17,19 +17,22 @@ 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),
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
+
// 20–50%, danger at <=20% (view.bankBarVariant).
|
|
29
|
+
// Each bar colors by its own fill, NOT the account's threshold state — the
|
|
30
|
+
// StatusTag keeps the account-state colors. Hosts whose backend predates
|
|
31
|
+
// top_up_bank_granted degrade gracefully: the bank renders as a text-only
|
|
32
|
+
// line (no bar, never "of 0"). An account with NO bank credits left (never
|
|
33
|
+
// topped up, or the bank is spent) renders exactly the legacy view — the
|
|
34
|
+
// "{remaining} of {granted} credits left" headline plus the single
|
|
35
|
+
// fill-colored bar.
|
|
33
36
|
//
|
|
34
37
|
// Props:
|
|
35
38
|
// entitlement — the /v1/home entitlement union (uses the credits arm).
|
|
@@ -38,7 +41,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
38
41
|
const view = resolveCreditMeter(entitlement, creditMeter);
|
|
39
42
|
if (!view) return null;
|
|
40
43
|
|
|
41
|
-
const { granted, used, remaining, bank, tagVariant, tagLabel,
|
|
44
|
+
const { granted, used, remaining, bank, tagVariant, tagLabel, poolBarVariant, bankBarVariant, alert } = view;
|
|
42
45
|
const grantDaysLeft = view.grantExpiresAt ? daysUntil(view.grantExpiresAt) : null;
|
|
43
46
|
|
|
44
47
|
return (
|
|
@@ -62,7 +65,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
62
65
|
value={bank.poolUsed}
|
|
63
66
|
maxValue={bank.poolGrant > 0 ? bank.poolGrant : 1}
|
|
64
67
|
showPercentage={true}
|
|
65
|
-
variant={
|
|
68
|
+
variant={poolBarVariant}
|
|
66
69
|
/>
|
|
67
70
|
</Flex>
|
|
68
71
|
{bank.bankGranted > 0 ? (
|
|
@@ -78,7 +81,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
78
81
|
value={bank.bankRemaining}
|
|
79
82
|
maxValue={bank.bankGranted > 0 ? bank.bankGranted : 1}
|
|
80
83
|
showPercentage={true}
|
|
81
|
-
variant=
|
|
84
|
+
variant={bankBarVariant}
|
|
82
85
|
/>
|
|
83
86
|
</Flex>
|
|
84
87
|
) : (
|
|
@@ -104,7 +107,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
104
107
|
value={used}
|
|
105
108
|
maxValue={granted > 0 ? granted : 1}
|
|
106
109
|
showPercentage={true}
|
|
107
|
-
variant={
|
|
110
|
+
variant={poolBarVariant}
|
|
108
111
|
/>
|
|
109
112
|
</>
|
|
110
113
|
)}
|
package/src/index.js
CHANGED
package/src/lib/creditDisplay.js
CHANGED
|
@@ -51,15 +51,41 @@ 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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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 20–50%, danger at <= 20%
|
|
74
|
+
// ("running out") — a draining bank is warning-worthy even though it
|
|
75
|
+
// never expires.
|
|
76
|
+
function poolBarVariantFor(poolUsed, poolGrant) {
|
|
77
|
+
if (!(poolGrant > 0)) return "success"; // no grant: nothing to deplete
|
|
78
|
+
if (poolUsed * 100 >= 95 * poolGrant) return "danger";
|
|
79
|
+
if (poolUsed * 100 >= 80 * poolGrant) return "warning";
|
|
80
|
+
return "success";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bankBarVariantFor(bankRemaining, bankGranted) {
|
|
84
|
+
if (!(bankGranted > 0)) return "success"; // degraded text-only line renders no bar
|
|
85
|
+
if (bankRemaining * 100 <= 20 * bankGranted) return "danger";
|
|
86
|
+
if (bankRemaining * 100 <= 50 * bankGranted) return "warning";
|
|
87
|
+
return "success";
|
|
88
|
+
}
|
|
63
89
|
export function resolveCreditMeter(entitlement, creditMeter) {
|
|
64
90
|
const ent = entitlement?.mode === "credits" ? entitlement : null;
|
|
65
91
|
const meter = creditMeter ?? null;
|
|
@@ -144,12 +170,16 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
144
170
|
tagLabel = "Healthy";
|
|
145
171
|
}
|
|
146
172
|
|
|
147
|
-
// ProgressBar
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
// ProgressBar sentiments — each bar colored by ITS OWN fill level (see the
|
|
174
|
+
// header block). The pool bar (and the no-bank legacy single bar) reads the
|
|
175
|
+
// pool's depletion ratio; the bank bar reads the bank's remaining level.
|
|
176
|
+
const poolBarVariant = poolBarVariantFor(
|
|
177
|
+
bank ? bank.poolUsed : used,
|
|
178
|
+
bank ? bank.poolGrant : granted
|
|
179
|
+
);
|
|
180
|
+
const bankBarVariant = bank
|
|
181
|
+
? bankBarVariantFor(bank.bankRemaining, bank.bankGranted)
|
|
182
|
+
: null;
|
|
153
183
|
|
|
154
184
|
// The inline meter alert mirrors the banner's messaging at the point of
|
|
155
185
|
// glance. Legacy branches keep their pre-top-up copy byte-for-byte.
|
|
@@ -202,7 +232,8 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
202
232
|
low,
|
|
203
233
|
tagVariant,
|
|
204
234
|
tagLabel,
|
|
205
|
-
|
|
235
|
+
poolBarVariant,
|
|
236
|
+
bankBarVariant,
|
|
206
237
|
alert,
|
|
207
238
|
grantExpiresAt: ent?.grant_expires_at ?? null,
|
|
208
239
|
};
|
package/src/sdk/app/topUps.js
CHANGED
|
@@ -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
|
-
|
|
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,
|