@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 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.16.0",
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
- // A paid plan is never blocked or "running low" the way the free tier is —
10
- // exceeding the included bucket is overage, not an emergency (see
11
- // common.entitlements.credits.to_entitlement's `over_included` field). Only
12
- // the free tier gets the blocking/urgent banners; a paid account in overage
13
- // gets a calm informational one instead.
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
- // Originally copy-pasted per app (a local `billingBannerFor` in each
16
- // Pages.jsx) — moved here after the SAME bug (independently re-deriving
17
- // urgency from raw `remaining`, no plan-awareness) had to be hand-fixed in
18
- // 3 places (confirmed live 2026-09-02, file-attachment-manager: a paid
19
- // account correctly kept working while this banner still showed the
20
- // blocking "out of credits" copy). Consume this instead of re-implementing
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 message, e.g.
30
- // "file actions" / "task actions". Falls back to "actions".
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 a "Go to Billing" button when provided;
34
- // omit it to render the banner with no button.
35
- export function BillingBanner({ state, actionLabel, actionsNoun, onNavigate }) {
36
- const ent = state?.entitlement;
37
- if (!ent || ent.mode !== "credits" || ent.status === "not_installed") {
38
- return null;
39
- }
40
-
41
- const onPaidPlan = !!ent.plan;
42
- let banner = null;
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
- if (!onPaidPlan && (ent.depleted === true || ent.entitled === false)) {
45
- banner = {
46
- variant: "error",
47
- title: "You're out of credits",
48
- message: `Your credits are used up. Choose a plan in Billing to keep ${actionLabel ?? "going"}.`,
49
- };
50
- } else if (onPaidPlan && ent.over_included === true) {
51
- banner = {
52
- variant: "warning",
53
- title: "You've used your included credits",
54
- message:
55
- "Extra usage this period is billed as overage at your plan's per-credit rate.",
56
- };
57
- } else if (
58
- !onPaidPlan &&
59
- typeof ent.remaining === "number" &&
60
- typeof ent.low_threshold === "number" &&
61
- ent.remaining <= ent.low_threshold
62
- ) {
63
- banner = {
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
- {onNavigate && (
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
- function CreditsBilling({ context, state, appKey, openIframe = null }) {
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
  }
@@ -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
- // Numbers resolve from the credit_meter block first (richer: carries `recent`),
12
- // then fall back to the entitlement arm. Thresholds (low_threshold, depleted,
13
- // grant_expires_at) only live on the entitlement arm.
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 ent = entitlement?.mode === "credits" ? entitlement : null;
20
- const meter = creditMeter ?? null;
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
- // healthy green / low or overage yellow / depleted red.
43
- const variant = depleted ? "error" : overIncluded || low ? "warning" : "success";
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
- <ProgressBar
69
- title={`${used} used`}
70
- value={used}
71
- maxValue={granted > 0 ? granted : 1}
72
- showPercentage={true}
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
- {ent?.grant_expires_at && grantDaysLeft != null && (
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
- {(depleted || overIncluded || low) && (
86
- <Alert
87
- title={
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,