@growth-operations/ui-shared 1.16.0 → 1.17.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/README.md +4 -1
- package/package.json +8 -1
- package/src/billing/BillingBanner.jsx +61 -55
- package/src/billing/BillingTab.jsx +258 -2
- package/src/home/CreditMeter.jsx +51 -62
- package/src/index.js +8 -0
- package/src/lib/creditDisplay.js +299 -0
- package/src/sdk/app/topUps.js +48 -0
- package/src/sdk/index.js +1 -0
package/README.md
CHANGED
|
@@ -127,6 +127,9 @@ Built on top of `callAppApi`:
|
|
|
127
127
|
- `getAlerts(context, params)` / `dismissAlert(context, alertId)` — the generic
|
|
128
128
|
`/api/v1/alerts` surface (`offset`, `limit`, `orderBy`, `orderDirection`,
|
|
129
129
|
`filterField`, `filterValue`).
|
|
130
|
+
- `getTopUps(context, { appKey, portalId })` / `setBillingLimit(context, { appKey, portalId, limitCents })` —
|
|
131
|
+
the base service's top-up billing routes (`/v1/hubspot/app_pages/{app}/billing/top-ups`
|
|
132
|
+
and `/billing/limit`; see CONTRACT.md).
|
|
130
133
|
|
|
131
134
|
### HubSpot public API — `callHubSpotApi`
|
|
132
135
|
|
|
@@ -186,7 +189,7 @@ only in a CRM-card context.
|
|
|
186
189
|
### SDK exports
|
|
187
190
|
|
|
188
191
|
`callAppApi`, `AppApiError`, `getSettings`, `updateSettings`, `makeSettingsApi`,
|
|
189
|
-
`getAlerts`, `dismissAlert`, `callHubSpotApi`, `buildHubSpotUrl`,
|
|
192
|
+
`getAlerts`, `dismissAlert`, `getTopUps`, `setBillingLimit`, `callHubSpotApi`, `buildHubSpotUrl`,
|
|
190
193
|
`HubSpotApiError`, `getObjectProperties`, `getProperties`, `updateProperties`,
|
|
191
194
|
`getForms`, `getLists`, `getPipelines`, `getPipelineStages`,
|
|
192
195
|
`computeStageChanges`, `getShortLivedToken`, `isTokenExpired`, `TokenProvider`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-operations/ui-shared",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.1",
|
|
4
4
|
"description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"publishConfig": {
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run"
|
|
17
|
+
},
|
|
15
18
|
"repository": {
|
|
16
19
|
"type": "git",
|
|
17
20
|
"url": "git+https://github.com/growth-operations/ui-shared.git"
|
|
@@ -19,5 +22,9 @@
|
|
|
19
22
|
"peerDependencies": {
|
|
20
23
|
"@hubspot/ui-extensions": ">=0.13.0",
|
|
21
24
|
"react": ">=18.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"react": "^18.3.1",
|
|
28
|
+
"vitest": "^5.0.3"
|
|
22
29
|
}
|
|
23
30
|
}
|
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Flex, Text, Alert, Button } from "@hubspot/ui-extensions";
|
|
3
|
+
import { resolveBillingBanner } from "../lib/creditDisplay";
|
|
3
4
|
|
|
4
5
|
// Shared app-wide billing banner for the credit archetype (CreditState.
|
|
5
6
|
// to_entitlement's credits arm). Meant to sit in the app's layout/shell
|
|
6
7
|
// (rendered above every route), NOT inside the Billing tab itself — it's the
|
|
7
8
|
// "surface this everywhere, not just on the Billing page" nudge.
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// Driven by the entitlement arm's `threshold_state` (the top-up/"bank"
|
|
11
|
+
// billing model):
|
|
12
|
+
// top_up_pending -> calm info ("Adding credits automatically…"), no CTA
|
|
13
|
+
// top_up_failed -> error, "Update payment method" CTA out to the Stripe
|
|
14
|
+
// billing-portal entry point (/v1/billing/portal/start)
|
|
15
|
+
// limit_reached -> warning, "Raise your billing limit" CTA into the
|
|
16
|
+
// Billing tab's limit control
|
|
17
|
+
// depleted -> error, BLOCKING wording — on the topup model a PAID
|
|
18
|
+
// plan blocks at true zero (pool + bank empty, no
|
|
19
|
+
// successful top-up): actions are paused until the next
|
|
20
|
+
// billing period or a successful top-up.
|
|
14
21
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// it — the next app in the wave should need zero new banner code.
|
|
22
|
+
// Legacy metered accounts are unchanged: they only ever see threshold_state
|
|
23
|
+
// "healthy"/"depleted" with over_included doing the talking — overage is
|
|
24
|
+
// "paying overage, not blocked" (the 2026-09-02 incident: a paid account
|
|
25
|
+
// correctly kept working while a banner showed blocking "out of credits"
|
|
26
|
+
// copy). The branch logic lives in ../lib/creditDisplay (unit-tested) —
|
|
27
|
+
// consume this instead of re-implementing it.
|
|
22
28
|
//
|
|
23
29
|
// Props:
|
|
24
30
|
// state — the /v1/home payload (entitlement union). Renders nothing
|
|
@@ -26,60 +32,60 @@ import { Flex, Text, Alert, Button } from "@hubspot/ui-extensions";
|
|
|
26
32
|
// actionLabel — short gerund phrase for the depleted message, e.g.
|
|
27
33
|
// "attaching and updating files" / "completing, reassigning,
|
|
28
34
|
// and deleting tasks". Falls back to "going" if omitted.
|
|
29
|
-
// actionsNoun — short noun phrase for the running-low
|
|
30
|
-
// "file actions" / "task actions". Falls back to
|
|
35
|
+
// actionsNoun — short noun phrase for the running-low / paused messages,
|
|
36
|
+
// e.g. "file actions" / "task actions". Falls back to
|
|
37
|
+
// "actions".
|
|
31
38
|
// onNavigate — in-app PageLink-equivalent callback (the host owns the
|
|
32
39
|
// router — see ui-shared's OnboardingChecklist for the same
|
|
33
|
-
// pattern). Renders
|
|
34
|
-
// omit it to render the banner with no button.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// pattern). Renders the in-app Billing CTA when provided;
|
|
41
|
+
// omit it to render the banner with no in-app button.
|
|
42
|
+
// context — the UI-extension serverless context (optional; only used
|
|
43
|
+
// for the portal link's return URL portal id).
|
|
44
|
+
export function BillingBanner({ state, actionLabel, actionsNoun, onNavigate, context }) {
|
|
45
|
+
const banner = resolveBillingBanner(state?.entitlement, {
|
|
46
|
+
actionLabel,
|
|
47
|
+
actionsNoun,
|
|
48
|
+
});
|
|
49
|
+
if (!banner) return null;
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
variant: "warning",
|
|
65
|
-
title: "Running low on credits",
|
|
66
|
-
message: `You're almost out of credits. Pick a plan in Billing so your ${
|
|
67
|
-
actionsNoun ?? "actions"
|
|
68
|
-
} don't get interrupted.`,
|
|
69
|
-
};
|
|
51
|
+
// "Update payment method" links out to the billing service's
|
|
52
|
+
// GET /v1/billing/portal/start (resolves the customer + creates the Stripe
|
|
53
|
+
// Customer Portal session server-side, 303s to Stripe in the opened tab) —
|
|
54
|
+
// the same entry point BillingTab's "Manage subscription" uses. Built only
|
|
55
|
+
// when the portal CTA is in play; null until billing_base_url + the signed
|
|
56
|
+
// action token are both known, in which case the CTA falls back to the
|
|
57
|
+
// in-app Billing tab (which renders the same portal link once loaded).
|
|
58
|
+
let portalStartUrl = null;
|
|
59
|
+
if (banner.cta?.kind === "portal") {
|
|
60
|
+
const base = state?.billing_base_url ?? null;
|
|
61
|
+
const portalToken = state?.billing_action_tokens?.portal ?? null;
|
|
62
|
+
if (base && portalToken) {
|
|
63
|
+
const returnUrl = state?.app_id
|
|
64
|
+
? `https://app.hubspot.com/app/${context?.portal?.id}/${state.app_id}/billing`
|
|
65
|
+
: "https://app.hubspot.com/";
|
|
66
|
+
portalStartUrl =
|
|
67
|
+
`${base}/v1/billing/portal/start` +
|
|
68
|
+
`?token=${encodeURIComponent(portalToken)}` +
|
|
69
|
+
`&return_url=${encodeURIComponent(returnUrl)}`;
|
|
70
|
+
}
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
if (!banner) return null;
|
|
73
|
-
|
|
74
73
|
return (
|
|
75
74
|
<Alert title={banner.title} variant={banner.variant}>
|
|
76
75
|
<Flex direction="column" gap="extra-small">
|
|
77
76
|
<Text>{banner.message}</Text>
|
|
78
|
-
{
|
|
77
|
+
{banner.cta?.kind === "portal" && portalStartUrl ? (
|
|
78
|
+
<Button
|
|
79
|
+
variant="secondary"
|
|
80
|
+
href={{ url: portalStartUrl, external: true }}
|
|
81
|
+
>
|
|
82
|
+
{banner.cta.label}
|
|
83
|
+
</Button>
|
|
84
|
+
) : banner.cta && onNavigate ? (
|
|
79
85
|
<Button variant="secondary" onClick={() => onNavigate("/billing")}>
|
|
80
|
-
Go to Billing →
|
|
86
|
+
{banner.cta.kind === "portal" ? "Go to Billing →" : banner.cta.label}
|
|
81
87
|
</Button>
|
|
82
|
-
)}
|
|
88
|
+
) : null}
|
|
83
89
|
</Flex>
|
|
84
90
|
</Alert>
|
|
85
91
|
);
|
|
@@ -7,11 +7,22 @@ import {
|
|
|
7
7
|
LoadingButton,
|
|
8
8
|
Button,
|
|
9
9
|
Alert,
|
|
10
|
+
Input,
|
|
11
|
+
LoadingSpinner,
|
|
12
|
+
Table,
|
|
13
|
+
TableHead,
|
|
14
|
+
TableHeader,
|
|
15
|
+
TableBody,
|
|
16
|
+
TableRow,
|
|
17
|
+
TableCell,
|
|
10
18
|
} from "@hubspot/ui-extensions";
|
|
11
|
-
import { fmtDate, daysUntil } from "../lib/format";
|
|
19
|
+
import { fmtDate, fmtMoney, daysUntil } from "../lib/format";
|
|
20
|
+
import { parseLimitInput } from "../lib/creditDisplay";
|
|
21
|
+
import { useStrictModeEffect } from "../lib/useStrictModeEffect";
|
|
12
22
|
import { CreditMeter } from "../home/CreditMeter";
|
|
13
23
|
import { PlanGrid } from "./PlanGrid";
|
|
14
24
|
import { refreshBillingActionTokens } from "../sdk/billing";
|
|
25
|
+
import { getTopUps, setBillingLimit } from "../sdk/app/topUps";
|
|
15
26
|
|
|
16
27
|
// Billing action tokens (see common.billing.action_token) are signed with a
|
|
17
28
|
// 5-minute TTL — short enough that a customer who opens the tab and comes
|
|
@@ -381,7 +392,230 @@ function TrialSubscriptionBilling({ context, state, appKey, openIframe = null })
|
|
|
381
392
|
);
|
|
382
393
|
}
|
|
383
394
|
|
|
384
|
-
|
|
395
|
+
// The top-up purchase table itself — pure rendering (exported for tests);
|
|
396
|
+
// fetch/loading/error live in TopUpHistory below.
|
|
397
|
+
export function TopUpHistoryTable({ topUps }) {
|
|
398
|
+
return (
|
|
399
|
+
<>
|
|
400
|
+
<Table>
|
|
401
|
+
<TableHead>
|
|
402
|
+
<TableRow>
|
|
403
|
+
<TableHeader>Date</TableHeader>
|
|
404
|
+
<TableHeader>Credits</TableHeader>
|
|
405
|
+
<TableHeader>Price</TableHeader>
|
|
406
|
+
<TableHeader>Note</TableHeader>
|
|
407
|
+
</TableRow>
|
|
408
|
+
</TableHead>
|
|
409
|
+
<TableBody>
|
|
410
|
+
{topUps.map((topUp, i) => (
|
|
411
|
+
<TableRow key={`${topUp.at}-${i}`}>
|
|
412
|
+
<TableCell>{fmtDate(topUp.at)}</TableCell>
|
|
413
|
+
<TableCell>{topUp.credits}</TableCell>
|
|
414
|
+
<TableCell>{fmtMoney(topUp.price_cents)}</TableCell>
|
|
415
|
+
<TableCell>Does not expire</TableCell>
|
|
416
|
+
</TableRow>
|
|
417
|
+
))}
|
|
418
|
+
</TableBody>
|
|
419
|
+
</Table>
|
|
420
|
+
<Text format={{ fontStyle: "italic" }}>
|
|
421
|
+
Additional credits don't expire — they draw down only after your
|
|
422
|
+
monthly plan credits.
|
|
423
|
+
</Text>
|
|
424
|
+
</>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// "Top-up history" — the credit app's prepaid top-up purchases (the
|
|
429
|
+
// never-expiring bank), from the base service's
|
|
430
|
+
// GET /v1/hubspot/app_pages/{app}/billing/top-ups (newest first). Loading,
|
|
431
|
+
// error, and empty states; the table mirrors AlertsTab's shape.
|
|
432
|
+
function TopUpHistory({ context, appKey }) {
|
|
433
|
+
const [status, setStatus] = useState("loading"); // loading | ready | error
|
|
434
|
+
const [topUps, setTopUps] = useState([]);
|
|
435
|
+
|
|
436
|
+
useStrictModeEffect(
|
|
437
|
+
async ({ mounted }) => {
|
|
438
|
+
try {
|
|
439
|
+
const res = await getTopUps(context, {
|
|
440
|
+
appKey,
|
|
441
|
+
portalId: context?.portal?.id,
|
|
442
|
+
});
|
|
443
|
+
if (mounted.current) {
|
|
444
|
+
setTopUps(res?.top_ups ?? []);
|
|
445
|
+
setStatus("ready");
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
if (mounted.current) setStatus("error");
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
[context, appKey]
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
if (status === "loading") {
|
|
455
|
+
return <LoadingSpinner showLabel label="Loading top-ups…" />;
|
|
456
|
+
}
|
|
457
|
+
if (status === "error") {
|
|
458
|
+
return (
|
|
459
|
+
<Alert title="Couldn't load top-up history" variant="warning">
|
|
460
|
+
<Text>Refresh the page to try again.</Text>
|
|
461
|
+
</Alert>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<Flex direction="column" gap="small">
|
|
467
|
+
<Heading>Top-up history</Heading>
|
|
468
|
+
{topUps.length === 0 ? (
|
|
469
|
+
<Text format={{ fontStyle: "italic" }}>
|
|
470
|
+
No top-ups yet. Additional credits you purchase are listed here —
|
|
471
|
+
they don't expire.
|
|
472
|
+
</Text>
|
|
473
|
+
) : (
|
|
474
|
+
<TopUpHistoryTable topUps={topUps} />
|
|
475
|
+
)}
|
|
476
|
+
</Flex>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// "Billing limit" — the per-period spend cap on automatic top-ups. Shows the
|
|
481
|
+
// current limit (when the entitlement arm carries billing_limit_cents), sets
|
|
482
|
+
// a new one (USD input -> cents), or clears it, via the base service's
|
|
483
|
+
// POST /v1/hubspot/app_pages/{app}/billing/limit. Success/error feedback is an
|
|
484
|
+
// inline Alert, consistent with the rest of this tab.
|
|
485
|
+
//
|
|
486
|
+
// The POST requires a billing action token (?token=) on top of verify_hubspot
|
|
487
|
+
// — billingActionToken should be the (interval-refreshed)
|
|
488
|
+
// state.billing_action_tokens.portal. Without it (an older backend serving
|
|
489
|
+
// /v1/home without minted tokens) the set/clear buttons stay disabled rather
|
|
490
|
+
// than failing with a 401 — the same "hidden until token exists" posture as
|
|
491
|
+
// the restart link.
|
|
492
|
+
function BillingLimitControl({
|
|
493
|
+
context,
|
|
494
|
+
appKey,
|
|
495
|
+
currentLimitCents = null,
|
|
496
|
+
billingActionToken = null,
|
|
497
|
+
}) {
|
|
498
|
+
const [limitCents, setLimitCents] = useState(currentLimitCents ?? null);
|
|
499
|
+
const [value, setValue] = useState("");
|
|
500
|
+
const [saving, setSaving] = useState(false);
|
|
501
|
+
const [notice, setNotice] = useState(null); // { variant, text }
|
|
502
|
+
|
|
503
|
+
// The entitlement arrives with /v1/home; adopt a changed server-side limit.
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
setLimitCents(currentLimitCents ?? null);
|
|
506
|
+
}, [currentLimitCents]);
|
|
507
|
+
|
|
508
|
+
const canSubmit = !saving && !!billingActionToken;
|
|
509
|
+
|
|
510
|
+
const save = async () => {
|
|
511
|
+
const parsed = parseLimitInput(value);
|
|
512
|
+
if (!parsed.ok) {
|
|
513
|
+
setNotice({ variant: "error", text: parsed.error });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
setSaving(true);
|
|
517
|
+
setNotice(null);
|
|
518
|
+
try {
|
|
519
|
+
await setBillingLimit(context, {
|
|
520
|
+
appKey,
|
|
521
|
+
portalId: context?.portal?.id,
|
|
522
|
+
limitCents: parsed.limitCents,
|
|
523
|
+
token: billingActionToken,
|
|
524
|
+
});
|
|
525
|
+
setLimitCents(parsed.limitCents);
|
|
526
|
+
setValue("");
|
|
527
|
+
setNotice({
|
|
528
|
+
variant: "success",
|
|
529
|
+
text: `Billing limit set to ${fmtMoney(parsed.limitCents)} per billing period.`,
|
|
530
|
+
});
|
|
531
|
+
} catch (e) {
|
|
532
|
+
setNotice({
|
|
533
|
+
variant: "error",
|
|
534
|
+
text: e?.message ?? "Couldn't update the billing limit.",
|
|
535
|
+
});
|
|
536
|
+
} finally {
|
|
537
|
+
setSaving(false);
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const clear = async () => {
|
|
542
|
+
setSaving(true);
|
|
543
|
+
setNotice(null);
|
|
544
|
+
try {
|
|
545
|
+
await setBillingLimit(context, {
|
|
546
|
+
appKey,
|
|
547
|
+
portalId: context?.portal?.id,
|
|
548
|
+
limitCents: null,
|
|
549
|
+
token: billingActionToken,
|
|
550
|
+
});
|
|
551
|
+
setLimitCents(null);
|
|
552
|
+
setNotice({
|
|
553
|
+
variant: "success",
|
|
554
|
+
text: "Billing limit cleared — automatic top-ups are uncapped.",
|
|
555
|
+
});
|
|
556
|
+
} catch (e) {
|
|
557
|
+
setNotice({
|
|
558
|
+
variant: "error",
|
|
559
|
+
text: e?.message ?? "Couldn't clear the billing limit.",
|
|
560
|
+
});
|
|
561
|
+
} finally {
|
|
562
|
+
setSaving(false);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
return (
|
|
567
|
+
<Flex direction="column" gap="small">
|
|
568
|
+
<Heading>Billing limit</Heading>
|
|
569
|
+
<Text>
|
|
570
|
+
Cap how much automatic credit top-ups can spend each billing period.
|
|
571
|
+
</Text>
|
|
572
|
+
<Text format={{ fontWeight: "bold" }}>
|
|
573
|
+
{limitCents != null
|
|
574
|
+
? `Current limit: ${fmtMoney(limitCents)} per billing period`
|
|
575
|
+
: "No limit set — automatic top-ups are uncapped."}
|
|
576
|
+
</Text>
|
|
577
|
+
<Flex direction="row" gap="small" align="end">
|
|
578
|
+
<Input
|
|
579
|
+
label="Limit (USD)"
|
|
580
|
+
name="billing-limit"
|
|
581
|
+
placeholder="e.g. 100.00"
|
|
582
|
+
value={value}
|
|
583
|
+
onChange={setValue}
|
|
584
|
+
/>
|
|
585
|
+
<LoadingButton
|
|
586
|
+
variant="primary"
|
|
587
|
+
loading={saving}
|
|
588
|
+
disabled={!canSubmit || !value.trim()}
|
|
589
|
+
onClick={save}
|
|
590
|
+
>
|
|
591
|
+
Set limit
|
|
592
|
+
</LoadingButton>
|
|
593
|
+
{limitCents != null && (
|
|
594
|
+
<Button variant="secondary" disabled={!canSubmit} onClick={clear}>
|
|
595
|
+
Clear limit
|
|
596
|
+
</Button>
|
|
597
|
+
)}
|
|
598
|
+
</Flex>
|
|
599
|
+
{!billingActionToken && (
|
|
600
|
+
<Text format={{ fontStyle: "italic" }}>
|
|
601
|
+
Preparing billing… the limit controls unlock in a moment. Refresh the
|
|
602
|
+
page if this persists.
|
|
603
|
+
</Text>
|
|
604
|
+
)}
|
|
605
|
+
{notice && (
|
|
606
|
+
<Alert
|
|
607
|
+
variant={notice.variant}
|
|
608
|
+
title={notice.variant === "success" ? "Saved" : "Couldn't save"}
|
|
609
|
+
>
|
|
610
|
+
<Text>{notice.text}</Text>
|
|
611
|
+
</Alert>
|
|
612
|
+
)}
|
|
613
|
+
</Flex>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Exported for tests (the billing_model gating of the top-up sections).
|
|
618
|
+
export function CreditsBilling({ context, state, appKey, openIframe = null }) {
|
|
385
619
|
const onPaidPlan = !!state?.entitlement?.plan;
|
|
386
620
|
|
|
387
621
|
// Direct external link to the billing service's GET /v1/billing/portal/start,
|
|
@@ -457,6 +691,28 @@ function CreditsBilling({ context, state, appKey, openIframe = null }) {
|
|
|
457
691
|
Growth Operations apps.
|
|
458
692
|
</Text>
|
|
459
693
|
)}
|
|
694
|
+
{/* Top-up ("bank") model surfaces — gated on the entitlement arm's
|
|
695
|
+
billing_model discriminator. "metered" accounts never top up, so
|
|
696
|
+
the sections would be permanently empty; backends that predate the
|
|
697
|
+
field don't have the endpoints at all. Both cases hide the
|
|
698
|
+
sections (the safe direction — no dead UI). */}
|
|
699
|
+
{state?.entitlement?.billing_model === "topup" && (
|
|
700
|
+
<>
|
|
701
|
+
<TopUpHistory context={context} appKey={appKey} />
|
|
702
|
+
<BillingLimitControl
|
|
703
|
+
context={context}
|
|
704
|
+
appKey={appKey}
|
|
705
|
+
// Populated for all accounts on the current contract
|
|
706
|
+
// (int cents | null; absent on older backends = no cap).
|
|
707
|
+
currentLimitCents={state?.entitlement?.billing_limit_cents ?? null}
|
|
708
|
+
// The limit POST's second auth dep (see setBillingLimit). `state`
|
|
709
|
+
// here is BillingTab's interval-refreshed copy, so this token is
|
|
710
|
+
// re-minted every 3 minutes — a long-open tab never POSTs an
|
|
711
|
+
// expired one.
|
|
712
|
+
billingActionToken={state?.billing_action_tokens?.portal ?? null}
|
|
713
|
+
/>
|
|
714
|
+
</>
|
|
715
|
+
)}
|
|
460
716
|
</Flex>
|
|
461
717
|
);
|
|
462
718
|
}
|
package/src/home/CreditMeter.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Flex, Tile, Text, ProgressBar, StatusTag, Alert } from "@hubspot/ui-extensions";
|
|
3
3
|
import { daysUntil } from "../lib/format";
|
|
4
|
+
import { resolveCreditMeter } from "../lib/creditDisplay";
|
|
4
5
|
|
|
5
6
|
// THE credit-archetype activity tile + hero signal. For credit apps, "credits
|
|
6
7
|
// remaining" is the primary glanceable signal the way "trial days left" is for
|
|
@@ -8,49 +9,26 @@ import { daysUntil } from "../lib/format";
|
|
|
8
9
|
// `credit_meter` block (CONTRACT.md). Renders nothing when there is no credit
|
|
9
10
|
// data to show.
|
|
10
11
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// All branch math (number precedence, threshold_state → tag/alert, the
|
|
13
|
+
// pool+bank split) lives in ../lib/creditDisplay so it's unit-testable — this
|
|
14
|
+
// component is pure rendering.
|
|
15
|
+
//
|
|
16
|
+
// Two-segment bank display: accounts with a top-up bank (topup billing model)
|
|
17
|
+
// get the combined marker bar — `${used} of ${granted}` over the combined
|
|
18
|
+
// pool+bank denominator, so after a period reset with bank left it reads e.g.
|
|
19
|
+
// "0 of 1,700" while the bank segment shows what's left — plus the
|
|
20
|
+
// pool/bank breakdown lines. Accounts with NO bank (top_up_bank_remaining 0
|
|
21
|
+
// AND never a top-up) render exactly the legacy single-segment bar.
|
|
14
22
|
//
|
|
15
23
|
// Props:
|
|
16
24
|
// entitlement — the /v1/home entitlement union (uses the credits arm).
|
|
17
25
|
// creditMeter — the /v1/home credit_meter block (optional).
|
|
18
26
|
export function CreditMeter({ entitlement, creditMeter }) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
if (!ent && !meter) return null;
|
|
22
|
-
|
|
23
|
-
const granted = meter?.granted ?? ent?.granted ?? 0;
|
|
24
|
-
const used = meter?.used ?? ent?.used ?? 0;
|
|
25
|
-
const remaining = meter?.remaining ?? ent?.remaining ?? Math.max(granted - used, 0);
|
|
26
|
-
|
|
27
|
-
// A paid plan is NEVER blocked — exceeding the included bucket is overage,
|
|
28
|
-
// not depletion (action_allowed_by_portal/consume_credits never gate a
|
|
29
|
-
// paid plan; see common.entitlements.credits.to_entitlement). So `depleted`
|
|
30
|
-
// must not fall back to raw `remaining <= 0` on a paid plan the way it does
|
|
31
|
-
// on the free tier — that raw check is exactly what showed a paying,
|
|
32
|
-
// already-subscribed customer a blocking "Depleted"/"out of credits" panel
|
|
33
|
-
// (confirmed live 2026-09-02). `over_included` is the distinct paid-plan
|
|
34
|
-
// signal for "you've used your included credits, extra usage is overage".
|
|
35
|
-
const onPaidPlan = !!ent?.plan;
|
|
36
|
-
const lowThreshold = ent?.low_threshold ?? 0;
|
|
37
|
-
const depleted = !onPaidPlan && (ent?.depleted === true || remaining <= 0);
|
|
38
|
-
const overIncluded =
|
|
39
|
-
onPaidPlan && (ent?.over_included === true || used > granted);
|
|
40
|
-
const low = !depleted && !overIncluded && remaining <= lowThreshold;
|
|
27
|
+
const view = resolveCreditMeter(entitlement, creditMeter);
|
|
28
|
+
if (!view) return null;
|
|
41
29
|
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const tagVariant = depleted ? "danger" : overIncluded || low ? "warning" : "success";
|
|
45
|
-
const tagLabel = depleted
|
|
46
|
-
? "Depleted"
|
|
47
|
-
: overIncluded
|
|
48
|
-
? "Over included"
|
|
49
|
-
: low
|
|
50
|
-
? "Running low"
|
|
51
|
-
: "Healthy";
|
|
52
|
-
|
|
53
|
-
const grantDaysLeft = ent?.grant_expires_at ? daysUntil(ent.grant_expires_at) : null;
|
|
30
|
+
const { granted, used, remaining, bank, tagVariant, tagLabel, alert } = view;
|
|
31
|
+
const grantDaysLeft = view.grantExpiresAt ? daysUntil(view.grantExpiresAt) : null;
|
|
54
32
|
|
|
55
33
|
return (
|
|
56
34
|
<Tile>
|
|
@@ -65,15 +43,41 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
65
43
|
{remaining} of {granted} credits left
|
|
66
44
|
</Text>
|
|
67
45
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
46
|
+
{bank ? (
|
|
47
|
+
<>
|
|
48
|
+
{/* Current-position marker over the COMBINED pool+bank
|
|
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
|
+
/>
|
|
59
|
+
<Flex direction="column" gap="extra-small">
|
|
60
|
+
<Text>
|
|
61
|
+
Monthly plan credits: {bank.poolRemaining} of {bank.poolGrant}{" "}
|
|
62
|
+
left
|
|
63
|
+
</Text>
|
|
64
|
+
<Text>Additional credits: {bank.bankRemaining} left</Text>
|
|
65
|
+
<Text format={{ fontStyle: "italic" }}>
|
|
66
|
+
Additional credits don't expire.
|
|
67
|
+
</Text>
|
|
68
|
+
</Flex>
|
|
69
|
+
</>
|
|
70
|
+
) : (
|
|
71
|
+
<ProgressBar
|
|
72
|
+
title={`${used} used`}
|
|
73
|
+
value={used}
|
|
74
|
+
maxValue={granted > 0 ? granted : 1}
|
|
75
|
+
showPercentage={true}
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
74
78
|
|
|
75
79
|
{/* Free-grant countdown: "100 free credits — N days left". */}
|
|
76
|
-
{
|
|
80
|
+
{view.grantExpiresAt && grantDaysLeft != null && (
|
|
77
81
|
<Text format={{ fontStyle: "italic" }}>
|
|
78
82
|
{granted} free credits —{" "}
|
|
79
83
|
{grantDaysLeft <= 0
|
|
@@ -82,24 +86,9 @@ export function CreditMeter({ entitlement, creditMeter }) {
|
|
|
82
86
|
</Text>
|
|
83
87
|
)}
|
|
84
88
|
|
|
85
|
-
{
|
|
86
|
-
<Alert
|
|
87
|
-
|
|
88
|
-
depleted
|
|
89
|
-
? "You're out of credits"
|
|
90
|
-
: overIncluded
|
|
91
|
-
? "You've used your included credits"
|
|
92
|
-
: "Running low on credits"
|
|
93
|
-
}
|
|
94
|
-
variant={variant}
|
|
95
|
-
>
|
|
96
|
-
<Text>
|
|
97
|
-
{depleted
|
|
98
|
-
? "Pick a plan or buy more credits to keep going."
|
|
99
|
-
: overIncluded
|
|
100
|
-
? "Extra usage this period is billed as overage at your plan's per-credit rate."
|
|
101
|
-
: "You're getting close to your limit — pick a plan to top up."}
|
|
102
|
-
</Text>
|
|
89
|
+
{alert && (
|
|
90
|
+
<Alert title={alert.title} variant={alert.variant}>
|
|
91
|
+
<Text>{alert.message}</Text>
|
|
103
92
|
</Alert>
|
|
104
93
|
)}
|
|
105
94
|
</Flex>
|
package/src/index.js
CHANGED
|
@@ -44,6 +44,12 @@ export {
|
|
|
44
44
|
STATUS_VARIANT,
|
|
45
45
|
} from "./lib/format";
|
|
46
46
|
export { useStrictModeEffect } from "./lib/useStrictModeEffect";
|
|
47
|
+
export {
|
|
48
|
+
hasTopUpBank,
|
|
49
|
+
resolveCreditMeter,
|
|
50
|
+
resolveBillingBanner,
|
|
51
|
+
parseLimitInput,
|
|
52
|
+
} from "./lib/creditDisplay";
|
|
47
53
|
|
|
48
54
|
// HubSpot URL builders (app.hubspot.com record/property/list links).
|
|
49
55
|
export {
|
|
@@ -65,6 +71,8 @@ export {
|
|
|
65
71
|
AppApiError,
|
|
66
72
|
getAlerts,
|
|
67
73
|
dismissAlert,
|
|
74
|
+
getTopUps,
|
|
75
|
+
setBillingLimit,
|
|
68
76
|
getSettings,
|
|
69
77
|
updateSettings,
|
|
70
78
|
makeSettingsApi,
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Pure display logic for the credits archetype's top-up ("bank") model —
|
|
2
|
+
// framework-free (no React) so the branches are unit-testable and the
|
|
3
|
+
// components stay thin. Source of truth for the semantics:
|
|
4
|
+
// common.entitlements.credits.CreditState.to_entitlement.
|
|
5
|
+
//
|
|
6
|
+
// THE MODEL IN BRIEF
|
|
7
|
+
// Credit apps get two buckets: a monthly pool (resets each billing period,
|
|
8
|
+
// no rollover) and a never-expiring prepaid top-up bank that draws down
|
|
9
|
+
// ONLY after the pool. Under the "topup" billing model the entitlement
|
|
10
|
+
// arm's granted/used/remaining are a COMBINED display:
|
|
11
|
+
// granted = period pool + bank remaining
|
|
12
|
+
// remaining = pool remaining + bank remaining
|
|
13
|
+
// used = granted - remaining (the current-position marker)
|
|
14
|
+
// so after a period reset with bank left the meter reads e.g. "0 of 1,700".
|
|
15
|
+
// Metered-model (legacy) paid accounts keep the RAW display (used grows
|
|
16
|
+
// past granted as overage) and only ever see threshold_state
|
|
17
|
+
// "healthy"/"depleted" with top_up_bank_remaining 0.
|
|
18
|
+
//
|
|
19
|
+
// BLOCKING (replaces the old "a paid plan is NEVER blocked" assumption):
|
|
20
|
+
// threshold_state === "depleted" is the ONLY hard-blocked state — a
|
|
21
|
+
// topup-model PAID plan DOES block at true zero (pool + bank empty, no
|
|
22
|
+
// successful top-up). A metered paid account in overage is NOT blocked:
|
|
23
|
+
// `over_included` always wins over a depleted reading (the backend mirrors
|
|
24
|
+
// this — metered overage keeps threshold_state "healthy" — and the UI
|
|
25
|
+
// double-guards so a stale/mixed payload can't regress the 2026-09-02
|
|
26
|
+
// incident where a paying, overage account was shown a blocking
|
|
27
|
+
// "Depleted"/"out of credits" panel).
|
|
28
|
+
|
|
29
|
+
// True when the account has (or ever had) a top-up bank: the two-segment
|
|
30
|
+
// meter renders only then — a zero bank with NO top-up ever (last_top_up_at
|
|
31
|
+
// null) renders exactly the legacy single-segment bar.
|
|
32
|
+
export function hasTopUpBank(ent) {
|
|
33
|
+
if (!ent) return false;
|
|
34
|
+
return (ent.top_up_bank_remaining ?? 0) > 0 || ent.last_top_up_at != null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolve every CreditMeter display decision from the /v1/home payload.
|
|
38
|
+
// Returns null when there is no credit data to show (component renders
|
|
39
|
+
// nothing). Otherwise:
|
|
40
|
+
// granted/used/remaining — combined (topup) or raw (legacy) display numbers
|
|
41
|
+
// bank — { bankRemaining, poolGrant, poolRemaining, poolUsed }
|
|
42
|
+
// when the account has a bank, else null
|
|
43
|
+
// thresholdState — the arm's threshold_state, or null on a backend
|
|
44
|
+
// that predates it
|
|
45
|
+
// depleted/overIncluded/low — mutually-exclusive urgency flags
|
|
46
|
+
// tagVariant/tagLabel — StatusTag rendering
|
|
47
|
+
// alert — { variant, title, message } or null
|
|
48
|
+
// grantExpiresAt — pass-through for the free-grant countdown
|
|
49
|
+
export function resolveCreditMeter(entitlement, creditMeter) {
|
|
50
|
+
const ent = entitlement?.mode === "credits" ? entitlement : null;
|
|
51
|
+
const meter = creditMeter ?? null;
|
|
52
|
+
if (!ent && !meter) return null;
|
|
53
|
+
|
|
54
|
+
// Number precedence: once the entitlement arm carries the top-up fields it
|
|
55
|
+
// is authoritative — the credit_meter block stays RAW pool-only under the
|
|
56
|
+
// topup model, so reading meter-first would hide the bank. On older
|
|
57
|
+
// backends (no top-up fields) keep the historical meter-first precedence
|
|
58
|
+
// (the meter is the richer block: it carries `recent`).
|
|
59
|
+
const entHasTopUpFields =
|
|
60
|
+
ent != null && typeof ent.top_up_bank_remaining === "number";
|
|
61
|
+
const pick = (key) =>
|
|
62
|
+
entHasTopUpFields ? ent[key] ?? meter?.[key] : meter?.[key] ?? ent?.[key];
|
|
63
|
+
|
|
64
|
+
const granted = pick("granted") ?? 0;
|
|
65
|
+
const used = pick("used") ?? 0;
|
|
66
|
+
const remaining = pick("remaining") ?? Math.max(granted - used, 0);
|
|
67
|
+
|
|
68
|
+
const onPaidPlan = !!ent?.plan;
|
|
69
|
+
const lowThreshold = ent?.low_threshold ?? 0;
|
|
70
|
+
const thresholdState =
|
|
71
|
+
typeof ent?.threshold_state === "string" ? ent.threshold_state : null;
|
|
72
|
+
|
|
73
|
+
const overIncluded =
|
|
74
|
+
onPaidPlan &&
|
|
75
|
+
(ent?.over_included === true || (thresholdState == null && used > granted));
|
|
76
|
+
const depleted =
|
|
77
|
+
thresholdState != null
|
|
78
|
+
? thresholdState === "depleted" && !overIncluded
|
|
79
|
+
: !onPaidPlan && (ent?.depleted === true || remaining <= 0);
|
|
80
|
+
const low = !depleted && !overIncluded && remaining <= lowThreshold;
|
|
81
|
+
|
|
82
|
+
// Bank split. Drawdown is monthly-first, so of the combined `remaining` the
|
|
83
|
+
// bank portion is bankRemaining and the rest is pool remaining; the pool
|
|
84
|
+
// segment's full size is the combined denominator minus the bank.
|
|
85
|
+
let bank = null;
|
|
86
|
+
if (hasTopUpBank(ent)) {
|
|
87
|
+
const bankRemaining = Math.max(0, ent?.top_up_bank_remaining ?? 0);
|
|
88
|
+
const poolGrant = Math.max(granted - bankRemaining, 0);
|
|
89
|
+
const poolRemaining = Math.min(Math.max(remaining - bankRemaining, 0), poolGrant);
|
|
90
|
+
bank = {
|
|
91
|
+
bankRemaining,
|
|
92
|
+
poolGrant,
|
|
93
|
+
poolRemaining,
|
|
94
|
+
poolUsed: poolGrant - poolRemaining,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let tagVariant;
|
|
99
|
+
let tagLabel;
|
|
100
|
+
if (depleted) {
|
|
101
|
+
tagVariant = "danger";
|
|
102
|
+
tagLabel = "Depleted";
|
|
103
|
+
} else if (thresholdState === "top_up_failed") {
|
|
104
|
+
tagVariant = "danger";
|
|
105
|
+
tagLabel = "Top-up failed";
|
|
106
|
+
} else if (thresholdState === "limit_reached") {
|
|
107
|
+
tagVariant = "warning";
|
|
108
|
+
tagLabel = "Limit reached";
|
|
109
|
+
} else if (thresholdState === "top_up_pending") {
|
|
110
|
+
tagVariant = "info";
|
|
111
|
+
tagLabel = "Adding credits";
|
|
112
|
+
} else if (overIncluded) {
|
|
113
|
+
tagVariant = "warning";
|
|
114
|
+
tagLabel = "Over included";
|
|
115
|
+
} else if (low) {
|
|
116
|
+
tagVariant = "warning";
|
|
117
|
+
tagLabel = "Running low";
|
|
118
|
+
} else {
|
|
119
|
+
tagVariant = "success";
|
|
120
|
+
tagLabel = "Healthy";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The inline meter alert mirrors the banner's messaging at the point of
|
|
124
|
+
// glance. Legacy branches keep their pre-top-up copy byte-for-byte.
|
|
125
|
+
let alert = null;
|
|
126
|
+
if (depleted) {
|
|
127
|
+
alert = {
|
|
128
|
+
variant: "error",
|
|
129
|
+
title: "You're out of credits",
|
|
130
|
+
message: onPaidPlan
|
|
131
|
+
? "Actions are paused until your next billing period or a successful top-up."
|
|
132
|
+
: "Choose a plan to resume your use.",
|
|
133
|
+
};
|
|
134
|
+
} else if (thresholdState === "top_up_failed") {
|
|
135
|
+
alert = {
|
|
136
|
+
variant: "error",
|
|
137
|
+
title: "We couldn't add credits",
|
|
138
|
+
message:
|
|
139
|
+
"The automatic top-up charge failed — update your payment method in Billing.",
|
|
140
|
+
};
|
|
141
|
+
} else if (thresholdState === "limit_reached") {
|
|
142
|
+
alert = {
|
|
143
|
+
variant: "warning",
|
|
144
|
+
title: "Billing limit reached",
|
|
145
|
+
message:
|
|
146
|
+
"Automatic top-ups are paused at your spending limit for this period. Raise your billing limit in Billing to resume them.",
|
|
147
|
+
};
|
|
148
|
+
} else if (overIncluded) {
|
|
149
|
+
alert = {
|
|
150
|
+
variant: "warning",
|
|
151
|
+
title: "You've used your included credits",
|
|
152
|
+
message:
|
|
153
|
+
"Extra usage this period is billed as overage at your plan's per-credit rate.",
|
|
154
|
+
};
|
|
155
|
+
} else if (low) {
|
|
156
|
+
alert = {
|
|
157
|
+
variant: "warning",
|
|
158
|
+
title: "Running low on credits",
|
|
159
|
+
message: "You're getting close to your limit — pick a plan to top up.",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
granted,
|
|
165
|
+
used,
|
|
166
|
+
remaining,
|
|
167
|
+
bank,
|
|
168
|
+
thresholdState,
|
|
169
|
+
depleted,
|
|
170
|
+
overIncluded,
|
|
171
|
+
low,
|
|
172
|
+
tagVariant,
|
|
173
|
+
tagLabel,
|
|
174
|
+
alert,
|
|
175
|
+
grantExpiresAt: ent?.grant_expires_at ?? null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Resolve the app-wide BillingBanner from the credits entitlement arm.
|
|
180
|
+
// Returns null to render nothing (non-credits mode, pre-install, or healthy),
|
|
181
|
+
// else { variant, title, message, cta } where cta is null or:
|
|
182
|
+
// { kind: "portal", label } — link out to the Stripe billing-portal entry
|
|
183
|
+
// point (payment-method fixes)
|
|
184
|
+
// { kind: "billing", label } — in-app navigate to the Billing tab
|
|
185
|
+
//
|
|
186
|
+
// Driven by threshold_state for the top-up model; the legacy metered branches
|
|
187
|
+
// (free depleted / paid over_included / free running-low) are byte-identical
|
|
188
|
+
// to the pre-top-up banner.
|
|
189
|
+
export function resolveBillingBanner(entitlement, { actionLabel, actionsNoun } = {}) {
|
|
190
|
+
const ent = entitlement;
|
|
191
|
+
if (!ent || ent.mode !== "credits" || ent.status === "not_installed") {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const onPaidPlan = !!ent.plan;
|
|
196
|
+
const thresholdState =
|
|
197
|
+
typeof ent.threshold_state === "string" ? ent.threshold_state : null;
|
|
198
|
+
const label = actionLabel ?? "going";
|
|
199
|
+
const noun = actionsNoun ?? "actions";
|
|
200
|
+
|
|
201
|
+
// --- Top-up (bank) model states. The backend only emits these three for
|
|
202
|
+
// topup-model accounts; each maps to exactly one banner. ---
|
|
203
|
+
if (thresholdState === "top_up_pending") {
|
|
204
|
+
return {
|
|
205
|
+
variant: "info",
|
|
206
|
+
title: "Adding credits automatically",
|
|
207
|
+
message:
|
|
208
|
+
"Your monthly credits ran low, so we're adding more with your payment method on file. Nothing to do — this usually takes a moment.",
|
|
209
|
+
cta: null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (thresholdState === "top_up_failed") {
|
|
213
|
+
return {
|
|
214
|
+
variant: "error",
|
|
215
|
+
title: "We couldn't add credits",
|
|
216
|
+
message: `The automatic top-up charge failed. Update your payment method so your ${noun} aren't interrupted.`,
|
|
217
|
+
cta: { kind: "portal", label: "Update payment method" },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (thresholdState === "limit_reached") {
|
|
221
|
+
return {
|
|
222
|
+
variant: "warning",
|
|
223
|
+
title: "Billing limit reached",
|
|
224
|
+
message:
|
|
225
|
+
"Automatic top-ups are paused — you've hit your spending limit for this billing period. Raise your billing limit to keep them going.",
|
|
226
|
+
cta: { kind: "billing", label: "Raise your billing limit" },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- Hard-blocked. threshold_state "depleted" is the ONLY blocking state
|
|
231
|
+
// (a topup-model paid plan blocks at true zero); `over_included` overrides
|
|
232
|
+
// a depleted reading so a metered paid account in overage is never shown as
|
|
233
|
+
// blocked. Backends that predate threshold_state fall back to the legacy
|
|
234
|
+
// derivation (only a non-paid account blocks at zero). ---
|
|
235
|
+
const overIncluded = onPaidPlan && ent.over_included === true;
|
|
236
|
+
const depleted =
|
|
237
|
+
thresholdState != null
|
|
238
|
+
? thresholdState === "depleted" && !overIncluded
|
|
239
|
+
: !onPaidPlan && (ent.depleted === true || ent.entitled === false);
|
|
240
|
+
|
|
241
|
+
if (depleted) {
|
|
242
|
+
return {
|
|
243
|
+
variant: "error",
|
|
244
|
+
title: "You're out of credits",
|
|
245
|
+
message: onPaidPlan
|
|
246
|
+
? `Your ${noun} are paused until your next billing period or a successful top-up.`
|
|
247
|
+
: `Your credits are used up. Choose a plan in Billing to keep ${label}.`,
|
|
248
|
+
cta: { kind: "billing", label: "Go to Billing →" },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (overIncluded) {
|
|
252
|
+
return {
|
|
253
|
+
variant: "warning",
|
|
254
|
+
title: "You've used your included credits",
|
|
255
|
+
message:
|
|
256
|
+
"Extra usage this period is billed as overage at your plan's per-credit rate.",
|
|
257
|
+
cta: { kind: "billing", label: "Go to Billing →" },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (
|
|
261
|
+
!onPaidPlan &&
|
|
262
|
+
typeof ent.remaining === "number" &&
|
|
263
|
+
typeof ent.low_threshold === "number" &&
|
|
264
|
+
ent.remaining <= ent.low_threshold
|
|
265
|
+
) {
|
|
266
|
+
return {
|
|
267
|
+
variant: "warning",
|
|
268
|
+
title: "Running low on credits",
|
|
269
|
+
message: `You're almost out of credits. Pick a plan in Billing so your ${noun} don't get interrupted.`,
|
|
270
|
+
cta: { kind: "billing", label: "Go to Billing →" },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Parse a USD billing-limit input ("$1,234.56", "50", "50.00") into cents.
|
|
278
|
+
// Returns { ok: true, limitCents } or { ok: false, error } with a
|
|
279
|
+
// customer-readable message. A limit must be a positive amount — clearing the
|
|
280
|
+
// limit is a separate action (null), not "0".
|
|
281
|
+
export function parseLimitInput(text) {
|
|
282
|
+
const cleaned = String(text ?? "")
|
|
283
|
+
.trim()
|
|
284
|
+
.replace(/[$,\s]/g, "");
|
|
285
|
+
if (!cleaned) {
|
|
286
|
+
return { ok: false, error: "Enter a dollar amount, e.g. 50 or 50.00." };
|
|
287
|
+
}
|
|
288
|
+
if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) {
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
error: "Enter a valid dollar amount (numbers only, up to 2 decimals).",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const limitCents = Math.round(parseFloat(cleaned) * 100);
|
|
295
|
+
if (!Number.isSafeInteger(limitCents) || limitCents <= 0) {
|
|
296
|
+
return { ok: false, error: "The limit must be more than $0." };
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, limitCents };
|
|
299
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Top-up ("bank") billing endpoints — the base service's app-agnostic
|
|
2
|
+
// /v1/hubspot/app_pages/{app}/billing/* routes (verify_hubspot; the platform
|
|
3
|
+
// injects Authorization, so like every callAppApi caller we send NO custom
|
|
4
|
+
// headers). Base-hosted credit apps reach base at context.variables.BASE_URL;
|
|
5
|
+
// `portalId` rides as a query param — the same pattern as the base-hosted
|
|
6
|
+
// alerts routes (see ./alerts).
|
|
7
|
+
import { callAppApi } from "./base";
|
|
8
|
+
|
|
9
|
+
function billingPath(appKey, suffix, portalId, { token } = {}) {
|
|
10
|
+
const path = `/v1/hubspot/app_pages/${encodeURIComponent(appKey)}/billing/${suffix}`;
|
|
11
|
+
const params = new URLSearchParams();
|
|
12
|
+
if (portalId != null) params.append("portalId", portalId);
|
|
13
|
+
if (token != null) params.append("token", token);
|
|
14
|
+
const query = params.toString();
|
|
15
|
+
return query ? `${path}?${query}` : path;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// GET /v1/hubspot/app_pages/{app}/billing/top-ups ->
|
|
19
|
+
// { top_ups: [{ at, credits, price_cents, period_key }] } (newest first)
|
|
20
|
+
// verify_hubspot only — no billing action token needed.
|
|
21
|
+
export async function getTopUps(context, { appKey, portalId } = {}) {
|
|
22
|
+
return callAppApi(context, billingPath(appKey, "top-ups", portalId), "GET");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// POST /v1/hubspot/app_pages/{app}/billing/limit, body { limit_cents: int|null }
|
|
26
|
+
// — set (or clear, with null) the per-period auto top-up spend cap. Body is a
|
|
27
|
+
// plain object, never JSON.stringify'd (the hubspot.fetch rule callAppApi
|
|
28
|
+
// enforces).
|
|
29
|
+
//
|
|
30
|
+
// The POST has a SECOND auth dep beyond verify_hubspot: a billing action
|
|
31
|
+
// token (common.billing.action_token) as a REQUIRED `token` query param —
|
|
32
|
+
// the same "recent, in-app access to THIS app's billing surface for THIS
|
|
33
|
+
// portal" proof the billing service's /v1/billing/*/start endpoints require
|
|
34
|
+
// (blocks the bare-portal_id hijack vector). Pass a fresh
|
|
35
|
+
// state.billing_action_tokens.portal (any action in the vocabulary verifies);
|
|
36
|
+
// BillingTab re-mints the set on an interval so a long-open tab never hands
|
|
37
|
+
// over an expired one.
|
|
38
|
+
export async function setBillingLimit(
|
|
39
|
+
context,
|
|
40
|
+
{ appKey, portalId, limitCents, token } = {}
|
|
41
|
+
) {
|
|
42
|
+
return callAppApi(
|
|
43
|
+
context,
|
|
44
|
+
billingPath(appKey, "limit", portalId, { token }),
|
|
45
|
+
"POST",
|
|
46
|
+
{ limit_cents: limitCents ?? null }
|
|
47
|
+
);
|
|
48
|
+
}
|
package/src/sdk/index.js
CHANGED
|
@@ -4,6 +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
8
|
export { getSettings, updateSettings, makeSettingsApi } from "./app/settings";
|
|
8
9
|
export {
|
|
9
10
|
strToBase64,
|