@growth-operations/ui-shared 1.9.0 → 1.10.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 +58 -4
- package/src/index.js +2 -0
- package/src/sdk/billing.js +28 -0
- package/src/sdk/index.js +3 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Flex,
|
|
4
4
|
Heading,
|
|
@@ -11,6 +11,46 @@ import {
|
|
|
11
11
|
import { fmtDate, daysUntil } from "../lib/format";
|
|
12
12
|
import { CreditMeter } from "../home/CreditMeter";
|
|
13
13
|
import { PlanGrid } from "./PlanGrid";
|
|
14
|
+
import { refreshBillingActionTokens } from "../sdk/billing";
|
|
15
|
+
|
|
16
|
+
// Billing action tokens (see common.billing.action_token) are signed with a
|
|
17
|
+
// 5-minute TTL — short enough that a customer who opens the tab and comes
|
|
18
|
+
// back later hits an expired Stripe link. Refresh at a fixed interval well
|
|
19
|
+
// under that TTL so a normally-paced re-render always has a token with
|
|
20
|
+
// several minutes of life left; if a refresh call is slow or fails, the
|
|
21
|
+
// still-live token already in state keeps working until the next tick.
|
|
22
|
+
const TOKEN_REFRESH_INTERVAL_MS = 3 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
// Re-mints state.billing_action_tokens on an interval so long-open tabs never
|
|
25
|
+
// hand the customer an expired Stripe link. Returns the latest known token
|
|
26
|
+
// set — the initial props value until the first successful refresh, then
|
|
27
|
+
// whatever the service last minted. Silently keeps the previous tokens on a
|
|
28
|
+
// failed refresh (e.g. transient network blip); the tokens simply expire on
|
|
29
|
+
// their own schedule as before this loop existed, which is a strict
|
|
30
|
+
// improvement (some refreshes succeeding beats none).
|
|
31
|
+
function useRefreshedBillingActionTokens(billingBaseUrl, initialTokens) {
|
|
32
|
+
const [tokens, setTokens] = useState(initialTokens ?? null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setTokens(initialTokens ?? null);
|
|
36
|
+
}, [initialTokens]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const portalToken = initialTokens?.portal;
|
|
40
|
+
if (!billingBaseUrl || !portalToken) return undefined;
|
|
41
|
+
|
|
42
|
+
const id = setInterval(() => {
|
|
43
|
+
refreshBillingActionTokens(billingBaseUrl, portalToken)
|
|
44
|
+
.then((fresh) => setTokens(fresh))
|
|
45
|
+
.catch(() => {});
|
|
46
|
+
}, TOKEN_REFRESH_INTERVAL_MS);
|
|
47
|
+
|
|
48
|
+
return () => clearInterval(id);
|
|
49
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
50
|
+
}, [billingBaseUrl, initialTokens?.portal]);
|
|
51
|
+
|
|
52
|
+
return tokens;
|
|
53
|
+
}
|
|
14
54
|
|
|
15
55
|
// The shared Billing tab for all Growth Operations apps. Branches on the
|
|
16
56
|
// entitlement union (CONTRACT.md):
|
|
@@ -412,6 +452,20 @@ export function BillingTab({ context, state, appKey, openIframe = null }) {
|
|
|
412
452
|
const mode = state?.entitlement?.mode;
|
|
413
453
|
const isSuperAdmin = state?.user?.is_super_admin === true;
|
|
414
454
|
|
|
455
|
+
// Keep the billing action tokens (portal/checkout/upgrade Stripe links)
|
|
456
|
+
// fresh for as long as this tab stays open. Overriding billing_action_tokens
|
|
457
|
+
// on a copy of `state` means every descendant (TrialSubscriptionBilling,
|
|
458
|
+
// CreditsBilling, PlanGrid) picks up refreshed tokens through the same
|
|
459
|
+
// `state?.billing_action_tokens` reads they already had — no prop threading.
|
|
460
|
+
const refreshedTokens = useRefreshedBillingActionTokens(
|
|
461
|
+
state?.billing_base_url,
|
|
462
|
+
state?.billing_action_tokens
|
|
463
|
+
);
|
|
464
|
+
const billingState =
|
|
465
|
+
refreshedTokens && refreshedTokens !== state?.billing_action_tokens
|
|
466
|
+
? { ...state, billing_action_tokens: refreshedTokens }
|
|
467
|
+
: state;
|
|
468
|
+
|
|
415
469
|
return (
|
|
416
470
|
<Flex direction="column" gap="medium">
|
|
417
471
|
<Heading>Billing</Heading>
|
|
@@ -420,13 +474,13 @@ export function BillingTab({ context, state, appKey, openIframe = null }) {
|
|
|
420
474
|
<Text>Billing settings are only available to super admins.</Text>
|
|
421
475
|
</Alert>
|
|
422
476
|
) : mode === "legacy" ? (
|
|
423
|
-
<LegacyBilling state={
|
|
477
|
+
<LegacyBilling state={billingState} />
|
|
424
478
|
) : mode === "credits" ? (
|
|
425
|
-
<CreditsBilling context={context} state={
|
|
479
|
+
<CreditsBilling context={context} state={billingState} appKey={appKey} openIframe={openIframe} />
|
|
426
480
|
) : mode === "comped" ? (
|
|
427
481
|
<CompedBilling />
|
|
428
482
|
) : (
|
|
429
|
-
<TrialSubscriptionBilling context={context} state={
|
|
483
|
+
<TrialSubscriptionBilling context={context} state={billingState} appKey={appKey} openIframe={openIframe} />
|
|
430
484
|
)}
|
|
431
485
|
</Flex>
|
|
432
486
|
);
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Billing-service SDK — direct calls to growth-ops-apps-app's billing
|
|
2
|
+
// endpoints (NOT the calling app's own backend, NOT api.hubapi.com). Every
|
|
3
|
+
// consuming app already allowlists ${BILLING_BASE_URL} under
|
|
4
|
+
// permittedUrls.fetch (it's also used for the Stripe-redirect <a href> links
|
|
5
|
+
// and the embedded-checkout iframe host), so hubspot.fetch can reach it
|
|
6
|
+
// directly with no new hsmeta changes.
|
|
7
|
+
//
|
|
8
|
+
// Auth here is the signed `token` query param (see
|
|
9
|
+
// common.billing.action_token) — NOT an Authorization header — so this is a
|
|
10
|
+
// bare hubspot.fetch, not callHubSpotApi (which always attaches a bearer
|
|
11
|
+
// token these endpoints don't expect).
|
|
12
|
+
import { hubspot, logger } from "@hubspot/ui-extensions";
|
|
13
|
+
|
|
14
|
+
// Re-mint a fresh { portal, checkout, upgrade } token set from a still-valid
|
|
15
|
+
// one. See billing.py's /v1/billing/refresh-tokens: it verifies `token`
|
|
16
|
+
// (whichever action it authorizes — BillingTab always holds/passes `portal`)
|
|
17
|
+
// and returns a brand-new set signed for the same app_key/portal_id. Throws
|
|
18
|
+
// on a 401 (token already expired) — the caller's next /v1/home load is the
|
|
19
|
+
// recovery path for that, same as before this refresh loop existed.
|
|
20
|
+
export async function refreshBillingActionTokens(billingBaseUrl, token) {
|
|
21
|
+
const url = `${billingBaseUrl}/v1/billing/refresh-tokens?token=${encodeURIComponent(token)}`;
|
|
22
|
+
const response = await hubspot.fetch(url, { timeout: 10000, method: "GET" });
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
logger.warn(`[Billing] refresh-tokens failed: ${response.status}`);
|
|
25
|
+
throw new Error(`refresh-tokens failed with status ${response.status}`);
|
|
26
|
+
}
|
|
27
|
+
return response.json();
|
|
28
|
+
}
|
package/src/sdk/index.js
CHANGED
|
@@ -32,3 +32,6 @@ export {
|
|
|
32
32
|
// Token lifecycle.
|
|
33
33
|
export { getShortLivedToken, isTokenExpired } from "./token/getShortLivedToken";
|
|
34
34
|
export { TokenProvider, useToken, TokenContext } from "./token/TokenContext";
|
|
35
|
+
|
|
36
|
+
// Billing service (growth-ops-apps-app), NOT the calling app's own backend.
|
|
37
|
+
export { refreshBillingActionTokens } from "./billing";
|