@growth-operations/ui-shared 1.17.2 → 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 +72 -36
- package/src/index.js +1 -0
- package/src/lib/creditDisplay.js +71 -8
- 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
|
@@ -9,16 +9,30 @@ import { resolveCreditMeter } from "../lib/creditDisplay";
|
|
|
9
9
|
// `credit_meter` block (CONTRACT.md). Renders nothing when there is no credit
|
|
10
10
|
// data to show.
|
|
11
11
|
//
|
|
12
|
-
// All branch math (number precedence, threshold_state → tag/alert
|
|
13
|
-
// pool+bank split) lives in ../lib/creditDisplay so it's
|
|
14
|
-
// component is pure rendering.
|
|
12
|
+
// All branch math (number precedence, threshold_state → tag/alert/bar
|
|
13
|
+
// variants, the pool+bank split) lives in ../lib/creditDisplay so it's
|
|
14
|
+
// unit-testable — this component is pure rendering.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// pool+bank denominator
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
16
|
+
// Stacked-bars bank display: an account with top-up bank credits left (topup
|
|
17
|
+
// billing model) gets TWO bars, one per bucket, and NO combined headline (the
|
|
18
|
+
// combined pool+bank denominator read as one big confusing allowance):
|
|
19
|
+
// 1. Monthly plan credits — a DEPLETION meter (value = pool used of the
|
|
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
|
+
// 2. Additional credits — a LEVEL gauge (value = bank remaining of the
|
|
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.
|
|
22
36
|
//
|
|
23
37
|
// Props:
|
|
24
38
|
// entitlement — the /v1/home entitlement union (uses the credits arm).
|
|
@@ -27,7 +41,7 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
27
41
|
const view = resolveCreditMeter(entitlement, creditMeter);
|
|
28
42
|
if (!view) return null;
|
|
29
43
|
|
|
30
|
-
const { granted, used, remaining, bank, tagVariant, tagLabel, alert } = view;
|
|
44
|
+
const { granted, used, remaining, bank, tagVariant, tagLabel, poolBarVariant, bankBarVariant, alert } = view;
|
|
31
45
|
const grantDaysLeft = view.grantExpiresAt ? daysUntil(view.grantExpiresAt) : null;
|
|
32
46
|
|
|
33
47
|
return (
|
|
@@ -38,42 +52,64 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
38
52
|
<StatusTag variant={tagVariant}>{tagLabel}</StatusTag>
|
|
39
53
|
</Flex>
|
|
40
54
|
|
|
41
|
-
{/* PRIMARY signal — large and obvious. */}
|
|
42
|
-
<Text format={{ fontWeight: "bold", fontSize: "lg" }}>
|
|
43
|
-
{remaining} of {granted} credits left
|
|
44
|
-
</Text>
|
|
45
|
-
|
|
46
55
|
{bank ? (
|
|
47
56
|
<>
|
|
48
|
-
{/*
|
|
49
|
-
denominator (used = granted - remaining). The HubSpot
|
|
50
|
-
ProgressBar is single-value, so the two segments are the
|
|
51
|
-
labeled breakdown below; this bar is the position within the
|
|
52
|
-
whole. */}
|
|
53
|
-
<ProgressBar
|
|
54
|
-
title={`${used} of ${granted} used`}
|
|
55
|
-
value={used}
|
|
56
|
-
maxValue={granted > 0 ? granted : 1}
|
|
57
|
-
showPercentage={true}
|
|
58
|
-
/>
|
|
57
|
+
{/* Pool — depletion meter over the period grant. */}
|
|
59
58
|
<Flex direction="column" gap="extra-small">
|
|
60
59
|
<Text>
|
|
61
60
|
Monthly plan credits: {bank.poolRemaining} of {bank.poolGrant}{" "}
|
|
62
61
|
left
|
|
63
62
|
</Text>
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
<ProgressBar
|
|
64
|
+
title={`${bank.poolUsed} of ${bank.poolGrant} used`}
|
|
65
|
+
value={bank.poolUsed}
|
|
66
|
+
maxValue={bank.poolGrant > 0 ? bank.poolGrant : 1}
|
|
67
|
+
showPercentage={true}
|
|
68
|
+
variant={poolBarVariant}
|
|
69
|
+
/>
|
|
68
70
|
</Flex>
|
|
71
|
+
{bank.bankGranted > 0 ? (
|
|
72
|
+
// Bank — level gauge over the granted total (refills on
|
|
73
|
+
// purchase, drains on spend; never expires).
|
|
74
|
+
<Flex direction="column" gap="extra-small">
|
|
75
|
+
<Text>
|
|
76
|
+
Additional credits (never expire): {bank.bankRemaining} of{" "}
|
|
77
|
+
{bank.bankGranted} left
|
|
78
|
+
</Text>
|
|
79
|
+
<ProgressBar
|
|
80
|
+
title={`${bank.bankRemaining} of ${bank.bankGranted} left`}
|
|
81
|
+
value={bank.bankRemaining}
|
|
82
|
+
maxValue={bank.bankGranted > 0 ? bank.bankGranted : 1}
|
|
83
|
+
showPercentage={true}
|
|
84
|
+
variant={bankBarVariant}
|
|
85
|
+
/>
|
|
86
|
+
</Flex>
|
|
87
|
+
) : (
|
|
88
|
+
// Graceful degradation: the host's backend predates
|
|
89
|
+
// top_up_bank_granted, so the bank total is unknown — text
|
|
90
|
+
// only, no bar (never divide by zero, never "of 0").
|
|
91
|
+
<Flex direction="column" gap="extra-small">
|
|
92
|
+
<Text>Additional credits: {bank.bankRemaining} left</Text>
|
|
93
|
+
<Text format={{ fontStyle: "italic" }}>
|
|
94
|
+
Additional credits don't expire.
|
|
95
|
+
</Text>
|
|
96
|
+
</Flex>
|
|
97
|
+
)}
|
|
69
98
|
</>
|
|
70
99
|
) : (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
<>
|
|
101
|
+
{/* PRIMARY signal — large and obvious. */}
|
|
102
|
+
<Text format={{ fontWeight: "bold", fontSize: "lg" }}>
|
|
103
|
+
{remaining} of {granted} credits left
|
|
104
|
+
</Text>
|
|
105
|
+
<ProgressBar
|
|
106
|
+
title={`${used} used`}
|
|
107
|
+
value={used}
|
|
108
|
+
maxValue={granted > 0 ? granted : 1}
|
|
109
|
+
showPercentage={true}
|
|
110
|
+
variant={poolBarVariant}
|
|
111
|
+
/>
|
|
112
|
+
</>
|
|
77
113
|
)}
|
|
78
114
|
|
|
79
115
|
{/* Free-grant countdown: "100 free credits — N days left". */}
|
package/src/index.js
CHANGED
package/src/lib/creditDisplay.js
CHANGED
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
// incident where a paying, overage account was shown a blocking
|
|
27
27
|
// "Depleted"/"out of credits" panel).
|
|
28
28
|
|
|
29
|
-
// True when the account has (or ever had) a top-up bank
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// True when the account has (or ever had) a top-up bank. Note the CreditMeter
|
|
30
|
+
// bank section renders only while the bank HAS credits left (bankRemaining >
|
|
31
|
+
// 0 — see resolveCreditMeter); this predicate stays the looser "ever had one"
|
|
32
|
+
// for consumers that render history/summary surfaces.
|
|
32
33
|
export function hasTopUpBank(ent) {
|
|
33
34
|
if (!ent) return false;
|
|
34
35
|
return (ent.top_up_bank_remaining ?? 0) > 0 || ent.last_top_up_at != null;
|
|
@@ -38,14 +39,53 @@ export function hasTopUpBank(ent) {
|
|
|
38
39
|
// Returns null when there is no credit data to show (component renders
|
|
39
40
|
// nothing). Otherwise:
|
|
40
41
|
// granted/used/remaining — combined (topup) or raw (legacy) display numbers
|
|
41
|
-
// bank — { bankRemaining,
|
|
42
|
-
// when the account has
|
|
42
|
+
// bank — { bankRemaining, bankGranted, poolGrant,
|
|
43
|
+
// poolRemaining, poolUsed } when the account has bank
|
|
44
|
+
// credits LEFT (bankRemaining > 0), else null — a
|
|
45
|
+
// zero bank renders the plain single-pool bar (with
|
|
46
|
+
// bankRemaining 0 the combined numbers ARE the pool
|
|
47
|
+
// numbers). bankGranted is the bank's lifetime
|
|
48
|
+
// granted total (the bank bar's denominator); 0 on
|
|
49
|
+
// backends that predate top_up_bank_granted — the
|
|
50
|
+
// component degrades to a text-only bank line.
|
|
43
51
|
// thresholdState — the arm's threshold_state, or null on a backend
|
|
44
52
|
// that predates it
|
|
45
53
|
// depleted/overIncluded/low — mutually-exclusive urgency flags
|
|
46
|
-
// tagVariant/tagLabel — StatusTag rendering
|
|
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)
|
|
47
60
|
// alert — { variant, title, message } or null
|
|
48
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
|
+
}
|
|
49
89
|
export function resolveCreditMeter(entitlement, creditMeter) {
|
|
50
90
|
const ent = entitlement?.mode === "credits" ? entitlement : null;
|
|
51
91
|
const meter = creditMeter ?? null;
|
|
@@ -82,13 +122,23 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
82
122
|
// Bank split. Drawdown is monthly-first, so of the combined `remaining` the
|
|
83
123
|
// bank portion is bankRemaining and the rest is pool remaining; the pool
|
|
84
124
|
// segment's full size is the combined denominator minus the bank.
|
|
125
|
+
//
|
|
126
|
+
// The bank section renders ONLY while the bank has credits left
|
|
127
|
+
// (bankRemaining > 0): a zero bank — never topped up, or fully burned —
|
|
128
|
+
// renders the plain single-pool bar, and with bankRemaining 0 the combined
|
|
129
|
+
// numbers ARE the pool numbers (granted == poolGrant, remaining ==
|
|
130
|
+
// poolRemaining), so nothing is lost.
|
|
85
131
|
let bank = null;
|
|
86
|
-
|
|
87
|
-
|
|
132
|
+
const bankRemaining = Math.max(0, ent?.top_up_bank_remaining ?? 0);
|
|
133
|
+
if (bankRemaining > 0) {
|
|
88
134
|
const poolGrant = Math.max(granted - bankRemaining, 0);
|
|
89
135
|
const poolRemaining = Math.min(Math.max(remaining - bankRemaining, 0), poolGrant);
|
|
90
136
|
bank = {
|
|
91
137
|
bankRemaining,
|
|
138
|
+
// The bank's lifetime granted total — the bank bar's denominator. 0 on
|
|
139
|
+
// backends that predate top_up_bank_granted (the component degrades to
|
|
140
|
+
// a text-only bank line; never divide by zero, never "of 0").
|
|
141
|
+
bankGranted: Math.max(0, ent?.top_up_bank_granted ?? 0),
|
|
92
142
|
poolGrant,
|
|
93
143
|
poolRemaining,
|
|
94
144
|
poolUsed: poolGrant - poolRemaining,
|
|
@@ -120,6 +170,17 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
120
170
|
tagLabel = "Healthy";
|
|
121
171
|
}
|
|
122
172
|
|
|
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;
|
|
183
|
+
|
|
123
184
|
// The inline meter alert mirrors the banner's messaging at the point of
|
|
124
185
|
// glance. Legacy branches keep their pre-top-up copy byte-for-byte.
|
|
125
186
|
let alert = null;
|
|
@@ -171,6 +232,8 @@ export function resolveCreditMeter(entitlement, creditMeter) {
|
|
|
171
232
|
low,
|
|
172
233
|
tagVariant,
|
|
173
234
|
tagLabel,
|
|
235
|
+
poolBarVariant,
|
|
236
|
+
bankBarVariant,
|
|
174
237
|
alert,
|
|
175
238
|
grantExpiresAt: ent?.grant_expires_at ?? null,
|
|
176
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,
|