Files
Stirling-PDF/frontend/editor/src/cloud/hooks/useWallet.ts
T
Connor Yoh edf688ef09 Prepaid bundles: capture prepay→PAYG consent at the quote step (ARL/EULA §7.2)
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.
2026-07-15 12:55:58 +01:00

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,
};
}