Affirmative consent, before payment, to the automatic transition to metered PAYG
when the prepaid term ends — required by California ARL (B&P §17602) / EULA §7.2.
- V41: consented_at + eula_version + price_minor on payg_bundle_quote (proof of
what was agreed). Additive + idempotent.
- PaygBundleController /quote takes { consented, eulaVersion }; PrepaidPurchase
service refuses a quote without consent and stamps the proof on the ticket.
- BundleCheckoutModal gates Continue on an un-pre-checked consent box disclosing
the metered rate + cancellation path; useWallet.quoteBundle forwards the EULA
version. Copy is placeholder pending legal.
Pairs with the SaaS-repo edge fn (create-payg-bundle-checkout), which refuses a
ticket without consent as defence in depth.
398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
/**
|
|
* Hook backing the PAYG Plan page. Wraps {@code GET /api/v1/payg/wallet}
|
|
* (served by {@code PaygWalletController} once Wave 1 BE lands; until then
|
|
* the dev preview route synthesises a wallet from localStorage) and exposes
|
|
* mutations for marking-subscribed and updating-the-cap.
|
|
*
|
|
* <h2>Render efficiency</h2>
|
|
*
|
|
* The hook is designed so {@code Plan}, {@code PaygFreeLeader/Member}, and
|
|
* {@code PaygLeader/Member} re-render only on actual data change:
|
|
*
|
|
* <ul>
|
|
* <li>{@link Wallet} snapshot is stored as a plain object — but every
|
|
* successful fetch deep-compares with the previous snapshot and reuses
|
|
* the prior reference if the payload is unchanged. Consumers that hold
|
|
* a stable {@code wallet} reference get stable child memoisation.
|
|
* <li>The returned {@link UseWalletResult} keeps stable callback identities
|
|
* via {@code useCallback}. {@code Plan} can pass {@code markSubscribed}
|
|
* to {@code UpgradeModal} without forcing a remount.
|
|
* <li>{@code refetch / markSubscribed / updateCap} bump an internal counter
|
|
* that the {@code useEffect} watches — no global state plumbing.
|
|
* <li>A monotonic {@code requestId} ref drops stale responses so a slow
|
|
* refetch from tick=N can't overwrite a faster one from tick=N+1
|
|
* (out-of-order resolution would otherwise show old data).
|
|
* </ul>
|
|
*
|
|
* <h2>Mutation semantics</h2>
|
|
*
|
|
* Both {@code markSubscribed} and {@code updateCap} resolve only after the
|
|
* post-mutation wallet refetch completes. So callers like the cap-editor
|
|
* "Update cap" button that gate a {@code loading} state on the returned
|
|
* promise see the UI flip exactly once the new state is visible — no
|
|
* intermediate flash of the old value.
|
|
*
|
|
* <h2>Dev preview fallback</h2>
|
|
*
|
|
* When the hook is rendered outside the saas app (e.g. on {@code
|
|
* /dev/payg-preview} during local design work) the {@code AppConfigContext}
|
|
* provider is not mounted and no backend is available. The hook detects that
|
|
* via the {@code @app/hooks/walletDevPreview} seam and, when it returns a live
|
|
* channel, falls back to a synthesised snapshot whose subscription state is
|
|
* read from {@code localStorage}. The detection + synthesis (which read
|
|
* {@code import.meta.env}, {@code window.location} and web storage — all banned
|
|
* in cloud/) live in the saas leaf's impl of that seam; this hook just consults
|
|
* it. Desktop's cascade falls through to the cloud default (no dev preview), so
|
|
* it always fetches the real wallet.
|
|
*/
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import apiClient from "@app/services/apiClient";
|
|
import { createPortalSession } from "@app/services/billing";
|
|
import { openExternal } from "@app/platform/openExternal";
|
|
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
|
|
import {
|
|
bundleListMinor,
|
|
bundlePriceMinor,
|
|
PREPAID_MONTHS_GRANTED,
|
|
PREPAID_MONTHS_PAID,
|
|
} from "@app/billing";
|
|
import type {
|
|
Wallet,
|
|
WalletStatus,
|
|
WalletRole,
|
|
WalletMember,
|
|
WalletCategoryBreakdown,
|
|
WalletActivityRow,
|
|
BundleQuote,
|
|
} from "@app/billing";
|
|
|
|
// ─── Public types ───────────────────────────────────────────────────────
|
|
// The wallet contract lives in @app/billing (shared with the admin portal).
|
|
// Re-exported so existing `@app/hooks/useWallet` importers keep their imports.
|
|
export type {
|
|
Wallet,
|
|
WalletStatus,
|
|
WalletRole,
|
|
WalletMember,
|
|
WalletCategoryBreakdown,
|
|
WalletActivityRow,
|
|
BundleQuote,
|
|
};
|
|
|
|
export interface UseWalletResult {
|
|
wallet: Wallet | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
/** Force a refetch — e.g. after Stripe redirects back into the app. */
|
|
refetch: () => Promise<void>;
|
|
/**
|
|
* Dev-only side-channel that simulates the Stripe webhook flipping the
|
|
* team to subscribed. Used by {@code UpgradeModal} when the backend is
|
|
* running the mock checkout — the real flow waits for the webhook
|
|
* instead and the next {@code refetch} picks up the change. Resolves
|
|
* once the post-mutation refetch completes.
|
|
*/
|
|
markSubscribed: (capUsd: number | null) => Promise<void>;
|
|
/**
|
|
* Update the team's monthly cap. {@code null} means "no cap". Resolves
|
|
* once the post-mutation refetch completes so a save-button
|
|
* {@code loading} state can be safely cleared on resolution.
|
|
*/
|
|
updateCap: (capUsd: number | null) => Promise<void>;
|
|
/**
|
|
* Mint a Stripe Customer Portal session and send the user to it. Mints the
|
|
* session via the {@code @app/services/billing} seam (passing the caller's
|
|
* {@code teamId}, which the PAYG portal edge function needs to resolve the
|
|
* team outside Spring Security) and opens the returned URL via the
|
|
* {@code @app/platform/openExternal} seam — so web and desktop each route it
|
|
* the platform-appropriate way (new tab on web, system browser on desktop).
|
|
* Throws on error so the caller can show a friendly toast — notably 404
|
|
* {@code team_not_subscribed}.
|
|
*/
|
|
openPortal: () => Promise<void>;
|
|
/**
|
|
* Price a prepaid-bundle purchase of {@code units} capacity, carrying the buyer's
|
|
* affirmative consent (ARL/EULA §7.2) as the agreed {@code eulaVersion} — captured
|
|
* before payment in the checkout modal. Leader-only on the backend ({@code POST
|
|
* /api/v1/payg/bundle/quote}), which refuses a quote without consent; returns a
|
|
* short-lived quote ticket the checkout flow hands to the bundle-checkout edge
|
|
* function. Throws on a non-2xx (403 members / 400 no consent).
|
|
*/
|
|
quoteBundle: (units: number, eulaVersion: string) => Promise<BundleQuote>;
|
|
}
|
|
|
|
// ─── Implementation ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Stable reference reuse — if the new payload deep-equals the previous one,
|
|
* return the previous object so React's reference check short-circuits child
|
|
* renders. Walks the top-level scalars first (cheapest), then the nested
|
|
* {@code categoryBreakdown} object, then the {@code members} array. The
|
|
* {@code recent} array is identity-compared only — Wave 1 always returns
|
|
* {@code []} so a reference-stability check is sufficient; we'll deepen
|
|
* this once the activity surface lands.
|
|
*/
|
|
function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
|
|
if (!prev) return next;
|
|
if (
|
|
prev.status !== next.status ||
|
|
prev.teamId !== next.teamId ||
|
|
prev.role !== next.role ||
|
|
prev.billingPeriodStart !== next.billingPeriodStart ||
|
|
prev.billingPeriodEnd !== next.billingPeriodEnd ||
|
|
prev.billableUsed !== next.billableUsed ||
|
|
prev.billableLimit !== next.billableLimit ||
|
|
prev.freeAllowance !== next.freeAllowance ||
|
|
prev.freeRemaining !== next.freeRemaining ||
|
|
prev.pricePerDocMinor !== next.pricePerDocMinor ||
|
|
prev.currency !== next.currency ||
|
|
prev.estimatedBillMinor !== next.estimatedBillMinor ||
|
|
prev.capUsd !== next.capUsd ||
|
|
prev.noCap !== next.noCap ||
|
|
prev.stripeSubscriptionId !== next.stripeSubscriptionId ||
|
|
prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod ||
|
|
prev.docsProcessedThisPeriod !== next.docsProcessedThisPeriod ||
|
|
prev.uniquePdfsThisPeriod !== next.uniquePdfsThisPeriod ||
|
|
prev.sizeMultiplierPdfsThisPeriod !== next.sizeMultiplierPdfsThisPeriod ||
|
|
prev.billingMode !== next.billingMode ||
|
|
prev.prepaidUnitsRemaining !== next.prepaidUnitsRemaining ||
|
|
prev.prepaidUnitsTotal !== next.prepaidUnitsTotal ||
|
|
prev.prepaidExpiresAt !== next.prepaidExpiresAt
|
|
) {
|
|
return next;
|
|
}
|
|
if (prev.recent.length !== next.recent.length) {
|
|
return next;
|
|
}
|
|
if (
|
|
prev.categoryBreakdown.api !== next.categoryBreakdown.api ||
|
|
prev.categoryBreakdown.ai !== next.categoryBreakdown.ai ||
|
|
prev.categoryBreakdown.automation !== next.categoryBreakdown.automation ||
|
|
prev.categoryDocs.api !== next.categoryDocs.api ||
|
|
prev.categoryDocs.ai !== next.categoryDocs.ai ||
|
|
prev.categoryDocs.automation !== next.categoryDocs.automation
|
|
) {
|
|
return next;
|
|
}
|
|
if (prev.members.length !== next.members.length) {
|
|
return next;
|
|
}
|
|
for (let i = 0; i < prev.members.length; i++) {
|
|
const a = prev.members[i];
|
|
const b = next.members[i];
|
|
if (
|
|
a.userId !== b.userId ||
|
|
a.name !== b.name ||
|
|
a.email !== b.email ||
|
|
a.spendUnits !== b.spendUnits
|
|
) {
|
|
return next;
|
|
}
|
|
}
|
|
// recent length-mismatch already returned `next` above; content (Wave 1 = []) is identical
|
|
// otherwise, so reuse the prior reference for stable child memoisation.
|
|
return prev;
|
|
}
|
|
|
|
export function useWallet(): UseWalletResult {
|
|
// Resolved once: the dev-preview side-channel when rendered outside the real
|
|
// app (saas /dev/payg-preview route), else null (every real build + desktop).
|
|
// The detection + synthesis live behind the @app/hooks/walletDevPreview seam
|
|
// because they read import.meta.env / window.location / localStorage, which
|
|
// cloud/ may not touch directly.
|
|
const devPreview = useRef(getWalletDevPreview()).current;
|
|
|
|
const [wallet, setWallet] = useState<Wallet | null>(null);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [refetchTick, setRefetchTick] = useState(0);
|
|
|
|
// Monotonic request id — used to discard stale responses if a faster
|
|
// refetch lands first. Only the latest issued id is permitted to commit
|
|
// its result.
|
|
const latestReqId = useRef(0);
|
|
|
|
// Promise tracking the most recent in-flight load. Mutations await this
|
|
// so their resolution semantics are "the new state is visible," not
|
|
// "the request fired." Cleared when no load is pending.
|
|
const inFlight = useRef<Promise<void> | null>(null);
|
|
|
|
useEffect(() => {
|
|
const reqId = ++latestReqId.current;
|
|
let cancelled = false;
|
|
|
|
const promise = (async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
if (devPreview) {
|
|
const synth = devPreview.buildWallet(devPreview.role());
|
|
if (cancelled || reqId !== latestReqId.current) return;
|
|
setWallet((prev) => reuseIfEqual(prev, synth));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
|
|
if (cancelled || reqId !== latestReqId.current) return;
|
|
setWallet((prev) => reuseIfEqual(prev, res.data));
|
|
} catch (e: unknown) {
|
|
if (cancelled || reqId !== latestReqId.current) return;
|
|
console.warn("[useWallet] fetch failed", e);
|
|
setError(e instanceof Error ? e.message : "Failed to load wallet");
|
|
} finally {
|
|
if (!cancelled && reqId === latestReqId.current) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
|
|
inFlight.current = promise;
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
// Don't clear inFlight here — let it resolve so mutations awaiting it
|
|
// still see a definitive "load completed" point. The reqId guard
|
|
// upstream ensures stale results don't commit.
|
|
};
|
|
}, [devPreview, refetchTick]);
|
|
|
|
const refetch = useCallback(async () => {
|
|
setRefetchTick((t) => t + 1);
|
|
// Snapshot the next-tick promise so the caller awaits this refetch
|
|
// specifically — the in-flight ref will be updated to it on the next
|
|
// effect run, but we can't reference that synchronously, so settle for
|
|
// a microtask handoff: await the *current* effect to flush, then await
|
|
// the new in-flight promise.
|
|
await Promise.resolve();
|
|
if (inFlight.current) {
|
|
await inFlight.current;
|
|
}
|
|
}, []);
|
|
|
|
const markSubscribed = useCallback(
|
|
async (capUsd: number | null) => {
|
|
if (devPreview) {
|
|
devPreview.markSubscribed();
|
|
await refetch();
|
|
return;
|
|
}
|
|
const noCap = capUsd === null;
|
|
// The dev side-channel only exists when the BE mock service is
|
|
// running (FE-branch local dev). Once the real backend (PR #6574)
|
|
// is in play, /dev/mark-subscribed is removed and the webhook
|
|
// (customer.subscription.created) is what flips the team to
|
|
// subscribed. We swallow 404s so the modal's completion path —
|
|
// which awaits this promise before rendering the confirmation
|
|
// screen — doesn't error out on a perfectly normal "the real
|
|
// backend doesn't expose this dev hook" response. A subsequent
|
|
// refetch picks up the webhook-driven flip whenever it lands.
|
|
try {
|
|
await apiClient.post("/api/v1/payg/dev/mark-subscribed", {
|
|
capUsd: capUsd ?? 0,
|
|
noCap,
|
|
});
|
|
} catch (e: unknown) {
|
|
const status =
|
|
typeof e === "object" && e !== null && "response" in e
|
|
? (e as { response?: { status?: number } }).response?.status
|
|
: undefined;
|
|
if (status === 404) {
|
|
// Real BE in play — webhook will land the subscription
|
|
// state; log and continue. Loud-but-harmless so the dev
|
|
// notices their /dev/mark-subscribed isn't wired up.
|
|
console.info(
|
|
"[useWallet] /dev/mark-subscribed not available (404) — relying on Stripe webhook to flip subscription state",
|
|
);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
await refetch();
|
|
},
|
|
[devPreview, refetch],
|
|
);
|
|
|
|
const updateCap = useCallback(
|
|
async (capUsd: number | null) => {
|
|
const noCap = capUsd === null;
|
|
if (devPreview) {
|
|
await refetch();
|
|
return;
|
|
}
|
|
await apiClient.patch("/api/v1/payg/cap", {
|
|
capUsd: capUsd ?? 0,
|
|
noCap,
|
|
});
|
|
await refetch();
|
|
},
|
|
[devPreview, refetch],
|
|
);
|
|
|
|
const openPortal = useCallback(async () => {
|
|
if (devPreview) {
|
|
// No real Stripe in dev preview — open a placeholder so the click still
|
|
// feels alive. Routed through the openExternal seam to stay portable.
|
|
await openExternal("https://billing.stripe.com/p/login/mock");
|
|
return;
|
|
}
|
|
// Mint the portal session through the billing seam, passing teamId: the
|
|
// PAYG portal edge function needs it to resolve the caller's team outside
|
|
// Spring Security (its RPC enforces team membership). Then hand the URL to
|
|
// the openExternal seam so each platform routes it appropriately. The seam
|
|
// throws on error (e.g. 404 team_not_subscribed) so callers can toast.
|
|
const teamId = wallet?.teamId;
|
|
if (teamId == null) {
|
|
throw new Error("No team resolved yet");
|
|
}
|
|
const { url } = await createPortalSession({ teamId });
|
|
await openExternal(url);
|
|
}, [devPreview, wallet?.teamId]);
|
|
|
|
const quoteBundle = useCallback(
|
|
async (units: number, eulaVersion: string): Promise<BundleQuote> => {
|
|
if (devPreview) {
|
|
// No backend on the dev-preview route — synthesise a quote off the
|
|
// synthesised wallet's rate so the calculator + checkout flow render.
|
|
const rate = wallet?.pricePerDocMinor ?? null;
|
|
const list = bundleListMinor(units, rate);
|
|
const total = bundlePriceMinor(units, rate);
|
|
return {
|
|
quoteId: -1,
|
|
units,
|
|
currency: wallet?.currency ?? "usd",
|
|
unitAmountMinor: rate,
|
|
listAmountMinor: list,
|
|
totalAmountMinor: total,
|
|
savingsMinor: list != null && total != null ? list - total : null,
|
|
monthsGranted: PREPAID_MONTHS_GRANTED,
|
|
monthsPaid: PREPAID_MONTHS_PAID,
|
|
expiresAt: "",
|
|
};
|
|
}
|
|
const res = await apiClient.post<BundleQuote>(
|
|
"/api/v1/payg/bundle/quote",
|
|
{
|
|
units,
|
|
consented: true,
|
|
eulaVersion,
|
|
},
|
|
);
|
|
return res.data;
|
|
},
|
|
[devPreview, wallet?.pricePerDocMinor, wallet?.currency],
|
|
);
|
|
|
|
return {
|
|
wallet,
|
|
loading,
|
|
error,
|
|
refetch,
|
|
markSubscribed,
|
|
updateCap,
|
|
openPortal,
|
|
quoteBundle,
|
|
};
|
|
}
|