payment informatics 4 (#1707)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-06-30 10:40:28 -04:00
1 parent 35add74498
commit b3311d1574
11 files changed
+565 -145

No files matched your search

+2
View File
@@ -138,6 +138,7 @@ RUN SQLX_OFFLINE=true \
$HOME/.cargo/bin/cargo build \
--release \
--bin storyteller-web \
--bin user-spend-analytics-job \
--bin seedance2-pro-job \
--bin worldlabs-job \
--bin video-thumbnail-job \
@@ -190,6 +191,7 @@ COPY --from=builder /tmp/target/release/analytics-job /
COPY --from=builder /tmp/target/release/email-sender-job /
COPY --from=builder /tmp/target/release/es-update-job /
COPY --from=builder /tmp/target/release/grok-api-job /
COPY --from=builder /tmp/target/release/user-spend-analytics-job /
# Legacy apps:
# COPY --from=builder /tmp/target/release/tts-download-job /
@@ -28,6 +28,8 @@ use crate::job::reengagement_score::reengagement_score;
use crate::job_dependencies::JobDependencies;
const DAYS_PER_WEEK: i64 = 7;
/// Emit a progress line every N users during the (long, otherwise silent) summaries pass.
const SUMMARY_LOG_EVERY: u64 = 200;
/// Recompute `user_spend_summaries` for every (user, namespace) with spend
/// activity. Window/cadence/score fields are time-relative, so all users are
@@ -66,8 +68,10 @@ pub async fn backfill_user_summaries(deps: &JobDependencies) {
}
async fn backfill_namespace(deps: &JobDependencies, namespace: PaymentsNamespace, now: DateTime<Utc>) -> u64 {
info!("User-summaries: backfilling namespace '{}'…", namespace.to_str());
let mut after_user_token = String::new();
let mut upserted = 0u64;
let mut processed = 0u64;
loop {
if deps.application_shutdown.get() {
@@ -115,12 +119,23 @@ async fn backfill_namespace(deps: &JobDependencies, namespace: PaymentsNamespace
tokio::time::sleep(deps.error_recovery).await;
}
}
processed += 1;
if processed % SUMMARY_LOG_EVERY == 0 {
info!(
" user-summaries [{}]: {processed} processed, {upserted} upserted so far…",
namespace.to_str(),
);
}
tokio::time::sleep(deps.query_delay).await;
}
after_user_token = user_tokens.last().expect("non-empty page").as_str().to_string();
}
info!(
"User-summaries: namespace '{}' done — {processed} processed, {upserted} upserted.",
namespace.to_str(),
);
upserted
}
@@ -24,6 +24,7 @@ import { JobTokenSearch } from "@/pages/JobTokenSearch";
import { DebugLogs } from "@/pages/DebugLogs";
import { DebugLogsSearch } from "@/pages/DebugLogsSearch";
import { SpendEvents } from "@/pages/SpendEvents";
import { ReengagementList } from "@/pages/ReengagementList";
import { UserSpendSummary } from "@/pages/UserSpendSummary";
import { UserSpendHistory } from "@/pages/UserSpendHistory";
import { NotFoundPage } from "@/pages/NotFound";
@@ -68,6 +69,7 @@ function AppRoutes() {
<Route path="user-signups" element={<UserSignups />} />
<Route path="subscriber-signups" element={<SubscriberSignups />} />
<Route path="spend-events" element={<SpendEvents />} />
<Route path="reengagement-list" element={<ReengagementList />} />
<Route path="impersonation" element={<Impersonation />} />
<Route path="staff-audit-logs" element={<StaffAuditLogs />} />
<Route path="send-pager" element={<SendPager />} />
@@ -161,7 +161,54 @@ export interface UserDailySpend {
updated_at: string;
}
export interface ReengagementCandidate {
user_token: string;
username: string;
display_name: string;
email_gravatar_hash: string;
reengagement_score: number;
lifetime_net_spend_usd_cents: number;
maybe_last_payment_at: string | null;
maybe_days_since_last_payment: number | null;
maybe_weeks_since_last_spend: number | null;
is_active_subscriber: boolean;
}
export class ModerationApi extends ApiManager {
// Users to re-engage (lapsed spenders), highest re-engagement score first.
public async ListReengagementCandidates(
offset?: number | null,
payments_namespace?: string,
): Promise<
ApiResponse<{
candidates: ReengagementCandidate[];
next_offset: number | null;
}>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_spend_summaries/reengagement_list`;
return await this.get<{
success: boolean;
candidates: ReengagementCandidate[];
maybe_next_offset: number | null;
error_message?: string;
}>({
endpoint,
query: {
offset: offset ?? undefined,
payments_namespace: payments_namespace ?? undefined,
},
})
.then((response) => ({
success: response.success,
data: {
candidates: response.candidates || [],
next_offset: response.maybe_next_offset ?? null,
},
errorMessage: response.error_message,
}))
.catch((err) => ({ success: false, errorMessage: err.message }));
}
// Full spend summary for one user.
public async GetUserSpendSummary(
user_token: string,
@@ -0,0 +1,161 @@
import { useState, useEffect } from "react";
import {
ModerationApi,
type ModeratorStripeCustomerIdSource,
type ModeratorUserStripeCustomerIdEntry,
} from "@/api/ModerationApi";
import { formatNamespace, stripeCustomerDashboardUrl } from "@/lib/stripe";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
IconAlertTriangle,
IconCheck,
IconCopy,
IconCreditCard,
IconExternalLink,
} from "@tabler/icons-react";
const STRIPE_SOURCE_LABELS: Record<ModeratorStripeCustomerIdSource, string> = {
customer_link: "Customer Link",
subscription: "Subscription",
};
/// The user's Stripe billing block (customer IDs across namespaces, their sources
/// — customer links + subscriptions — dashboard links, and a drift warning when a
/// namespace maps to more than one customer id). Self-contained: fetches its own data.
export function UserBillingSection({ userToken }: { userToken: string }) {
const [customerIds, setCustomerIds] = useState<ModeratorUserStripeCustomerIdEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
useEffect(() => {
let cancelled = false;
setIsLoading(true);
new ModerationApi()
.GetUserStripeCustomerIds(userToken)
.then((resp) => {
if (cancelled) return;
if (resp.success && resp.data) setCustomerIds(resp.data.customer_ids);
setIsLoading(false);
})
.catch(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [userToken]);
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedStates((prev) => ({ ...prev, [id]: true }));
setTimeout(() => {
setCopiedStates((prev) => ({ ...prev, [id]: false }));
}, 2000);
});
};
// One row per unique customer id, with all the (namespace, source) pairs it was found under.
const stripeCustomers = customerIds.reduce<
{ id: string; entries: ModeratorUserStripeCustomerIdEntry[] }[]
>((acc, entry) => {
const group = acc.find((g) => g.id === entry.stripe_customer_id);
if (group) {
group.entries.push(entry);
} else {
acc.push({ id: entry.stripe_customer_id, entries: [entry] });
}
return acc;
}, []);
// A namespace mapping to more than one distinct customer id means the
// subscription and credits customer drifted apart — worth flagging.
const namespaceIdSets = customerIds.reduce<Map<string, Set<string>>>(
(map, entry) => {
const ids = map.get(entry.payments_namespace) ?? new Set<string>();
ids.add(entry.stripe_customer_id);
return map.set(entry.payments_namespace, ids);
},
new Map(),
);
const mismatchedNamespaces = [...namespaceIdSets.entries()]
.filter(([, ids]) => ids.size > 1)
.map(([ns]) => ns);
return (
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5">
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-11.25">
<IconCreditCard className="size-4" />
Stripe Customer Data
</h4>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Stripe Customers
</span>
{isLoading ? (
<Skeleton className="h-12 w-full rounded-lg" />
) : stripeCustomers.length > 0 ? (
<div className="flex flex-col gap-2">
{stripeCustomers.map(({ id, entries }) => (
<div
key={id}
className="flex flex-col gap-1.5 p-2 bg-secondary/30 rounded-lg"
>
<div className="flex items-center gap-1.5 min-w-0">
<a
href={stripeCustomerDashboardUrl(id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 min-w-0 font-mono text-xs hover:underline"
title="Open in Stripe Dashboard"
>
<span className="truncate">{id}</span>
<IconExternalLink className="size-3 shrink-0 text-muted-foreground" />
</a>
<button
type="button"
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors cursor-pointer p-0.5"
onClick={() => copyToClipboard(id, `stripe_${id}`)}
title="Copy customer ID"
>
{copiedStates[`stripe_${id}`] ? (
<IconCheck className="size-3 text-emerald-400" />
) : (
<IconCopy className="size-3 opacity-50" />
)}
</button>
</div>
<div className="flex flex-wrap gap-1">
{entries.map((entry) => (
<Badge
key={`${entry.payments_namespace}_${entry.source}`}
variant="secondary"
className="text-[10px] py-0 px-1.5"
>
{formatNamespace(entry.payments_namespace)}
{" · "}
{STRIPE_SOURCE_LABELS[entry.source]}
</Badge>
))}
</div>
</div>
))}
{mismatchedNamespaces.length > 0 && (
<p className="flex items-center gap-1 text-[11px] text-amber-400">
<IconAlertTriangle className="size-3 shrink-0" />
Multiple customer IDs in{" "}
{mismatchedNamespaces.map(formatNamespace).join(", ")}
</p>
)}
</div>
) : (
<span className="text-sm text-muted-foreground italic">
Not Connected
</span>
)}
</div>
</div>
</div>
);
}
@@ -11,6 +11,7 @@ import {
IconKey,
IconClipboardList,
IconCoin,
IconHeartHandshake,
IconMail,
IconBellRinging,
IconFileAi,
@@ -84,6 +85,11 @@ const data = {
url: "/spend-events",
icon: IconCoin,
},
{
title: "Reengagement List",
url: "/reengagement-list",
icon: IconHeartHandshake,
},
{
title: "Impersonation Logs",
url: "/impersonation",
@@ -0,0 +1,266 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { ModerationApi, type ReengagementCandidate } from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconChartLine,
IconHeartHandshake,
IconReceipt2,
IconRefresh,
IconLoader2,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
const COLUMN_COUNT = 7;
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const formatUsd = (cents: number) => usd.format(cents / 100);
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function scoreColor(score: number): string {
if (score >= 800) return "text-emerald-400";
if (score >= 500) return "text-amber-400";
return "text-muted-foreground";
}
export function ReengagementList() {
usePageTitle("Reengagement List");
const [candidates, setCandidates] = useState<ReengagementCandidate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextOffset, setNextOffset] = useState<number | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadData = async (offset?: number | null, append = false) => {
if (!append) {
setIsLoading(true);
setError(null);
} else {
setIsLoadingMore(true);
}
try {
const modApi = new ModerationApi();
const resp = await modApi.ListReengagementCandidates(offset);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setCandidates((prev) =>
append ? [...prev, ...resp.data!.candidates] : resp.data!.candidates,
);
setNextOffset(resp.data.next_offset);
} else {
setError(resp.errorMessage || "Failed to load reengagement list");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load reengagement list");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
};
const handleRefresh = () => {
setCandidates([]);
setNextOffset(null);
loadData();
};
useEffect(() => {
cancelledRef.current = false;
loadData();
return () => {
cancelledRef.current = true;
};
}, []);
const loadMore = useCallback(() => {
if (!isLoadingMore && nextOffset != null) {
loadData(nextOffset, true);
}
}, [nextOffset, isLoadingMore]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) observerRef.current.observe(sentinelRef.current);
return () => observerRef.current?.disconnect();
}, [loadMore]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
const headerRow = (
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">User</TableHead>
<TableHead className="text-xs text-right">Score</TableHead>
<TableHead className="text-xs text-right">Lifetime Net</TableHead>
<TableHead className="text-xs">Last Payment</TableHead>
<TableHead className="text-xs text-right">Days Since</TableHead>
<TableHead className="text-xs text-right">Weeks Since</TableHead>
<TableHead className="text-xs">Subscriber</TableHead>
</TableRow>
);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconHeartHandshake className="size-6 text-muted-foreground" />
Reengagement List
</h1>
<p className="text-muted-foreground">
Lapsed spenders to win back, highest re-engagement score first
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>{headerRow}</TableHeader>
<TableBody>
{Array.from({ length: 12 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: COLUMN_COUNT }).map((__, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-20" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
) : candidates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconHeartHandshake className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No users to re-engage.</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">{headerRow}</TableHeader>
<TableBody>
{candidates.map((c) => (
<TableRow key={c.user_token} className="group">
<TableCell className="text-sm font-medium">
<div className="flex items-center gap-2">
<Link
to={`/user/profile/${c.username}`}
className="hover:underline text-foreground"
>
@{c.username}
</Link>
<span className="flex items-center gap-1.5">
<Link
to={`/user/spend-summary/${c.username}`}
className="text-muted-foreground hover:text-foreground"
title="Spend summary"
>
<IconChartLine className="size-4" />
</Link>
<span className="text-muted-foreground/30">|</span>
<Link
to={`/user/spend-history/${c.username}`}
className="text-muted-foreground hover:text-foreground"
title="Spend history"
>
<IconReceipt2 className="size-4" />
</Link>
</span>
</div>
</TableCell>
<TableCell className={`text-sm text-right font-semibold tabular-nums ${scoreColor(c.reengagement_score)}`}>
{c.reengagement_score}
</TableCell>
<TableCell className="text-sm text-right tabular-nums">
{formatUsd(c.lifetime_net_spend_usd_cents)}
</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{formatDate(c.maybe_last_payment_at)}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{c.maybe_days_since_last_payment != null ? `${c.maybe_days_since_last_payment}d` : "—"}
</TableCell>
<TableCell className="text-sm text-right tabular-nums text-muted-foreground">
{c.maybe_weeks_since_last_spend != null ? `${c.maybe_weeks_since_last_spend}w` : "—"}
</TableCell>
<TableCell className="text-xs">
{c.is_active_subscriber ? (
<Badge variant="outline" className="text-[10px] text-emerald-400 border-emerald-500/30">
active
</Badge>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
</TableRow>
))}
{nextOffset != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={COLUMN_COUNT} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">Scroll for more</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
@@ -17,7 +17,9 @@ import {
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconChartLine,
IconCoin,
IconReceipt2,
IconRefresh,
IconLoader2,
} from "@tabler/icons-react";
@@ -229,20 +231,34 @@ export function SpendEvents() {
</TableCell>
<TableCell className="text-sm font-medium">
{event.maybe_username ? (
<Link
to={`/user/profile/${event.maybe_username}`}
className="hover:underline text-foreground"
>
@{event.maybe_username}
</Link>
<div className="flex items-center gap-2">
<Link
to={`/user/profile/${event.maybe_username}`}
className="hover:underline text-foreground"
>
@{event.maybe_username}
</Link>
<span className="flex items-center gap-1.5">
<Link
to={`/user/spend-summary/${event.maybe_username}`}
className="text-muted-foreground hover:text-foreground"
title="Spend summary"
>
<IconChartLine className="size-4" />
</Link>
<span className="text-muted-foreground/30">|</span>
<Link
to={`/user/spend-history/${event.maybe_username}`}
className="text-muted-foreground hover:text-foreground"
title="Spend history"
>
<IconReceipt2 className="size-4" />
</Link>
</span>
</div>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
{event.maybe_display_name && (
<span className="ml-2 text-xs text-muted-foreground">
{event.maybe_display_name}
</span>
)}
</TableCell>
<TableCell className="text-xs">
<Badge
@@ -6,12 +6,10 @@ import * as Flags from "country-flag-icons/react/3x2";
import {
ModerationApi,
type ModeratorUserLookupResponse,
type ModeratorStripeCustomerIdSource,
type ModeratorUserStripeCustomerIdEntry,
} from "@/api/ModerationApi";
import { formatNamespace, stripeCustomerDashboardUrl } from "@/lib/stripe";
import { MediaApi, type MediaInfo } from "@/api/MediaApi";
import { MediaCard } from "@/components/MediaCard";
import { UserBillingSection } from "@/components/UserBillingSection";
import type { Wallet, UserJob } from "@/types";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getIdenticonUrl } from "@/lib/identicon";
@@ -19,9 +17,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
IconAlertCircle,
IconAlertTriangle,
IconArrowLeft,
IconExternalLink,
IconPhoto,
IconInfoCircle,
IconCopy,
@@ -29,10 +25,11 @@ import {
IconMail,
IconUser,
IconShieldLock,
IconChartLine,
IconCoin,
IconReceipt2,
IconEye,
IconEyeOff,
IconCreditCard,
IconCalendar,
IconHistory,
IconBriefcase,
@@ -63,11 +60,6 @@ import { UserFeatureFlagsDialog } from "@/components/UserFeatureFlagsDialog";
import { getJobStatusBadgeProps } from "@/components/JobsTable";
import { usePageTitle } from "@/hooks/usePageTitle";
const STRIPE_SOURCE_LABELS: Record<ModeratorStripeCustomerIdSource, string> = {
customer_link: "Customer Link",
subscription: "Subscription",
};
export function UserProfile() {
const { username } = useParams<{ username: string }>();
usePageTitle(username ? `@${username}` : undefined);
@@ -80,10 +72,6 @@ export function UserProfile() {
const [media, setMedia] = useState<MediaInfo[]>([]);
const [wallets, setWallets] = useState<Wallet[]>([]);
const [isWalletsLoading, setIsWalletsLoading] = useState(false);
const [stripeCustomerIds, setStripeCustomerIds] = useState<
ModeratorUserStripeCustomerIdEntry[]
>([]);
const [isStripeIdsLoading, setIsStripeIdsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showIPs, setShowIPs] = useState<boolean>(false);
@@ -101,34 +89,6 @@ export function UserProfile() {
const totalBanked = wallets.reduce((sum, w) => sum + w.banked_credits, 0);
const totalCredits = totalMonthly + totalBanked;
// One row per unique customer id, with all the (namespace, source) pairs
// it was found under.
const stripeCustomers = stripeCustomerIds.reduce<
{ id: string; entries: ModeratorUserStripeCustomerIdEntry[] }[]
>((acc, entry) => {
const group = acc.find((g) => g.id === entry.stripe_customer_id);
if (group) {
group.entries.push(entry);
} else {
acc.push({ id: entry.stripe_customer_id, entries: [entry] });
}
return acc;
}, []);
// A namespace mapping to more than one distinct customer id means the
// subscription and credits customer drifted apart — worth flagging.
const namespaceIdSets = stripeCustomerIds.reduce<Map<string, Set<string>>>(
(map, entry) => {
const ids = map.get(entry.payments_namespace) ?? new Set<string>();
ids.add(entry.stripe_customer_id);
return map.set(entry.payments_namespace, ids);
},
new Map(),
);
const mismatchedNamespaces = [...namespaceIdSets.entries()]
.filter(([, ids]) => ids.size > 1)
.map(([ns]) => ns);
const createWallet = async () => {
if (!user || isCreatingWallet) return;
setIsCreatingWallet(true);
@@ -241,14 +201,6 @@ export function UserProfile() {
setIsJobsLoading(false);
});
setIsStripeIdsLoading(true);
modApi.GetUserStripeCustomerIds(fetchedUser.token).then((resp) => {
if (cancelled) return;
if (resp.success && resp.data) {
setStripeCustomerIds(resp.data.customer_ids);
}
setIsStripeIdsLoading(false);
});
} else {
setError(userResp.errorMessage || "User not found");
}
@@ -606,13 +558,13 @@ export function UserProfile() {
</Button>
<Button variant="outline" size="sm" asChild>
<Link to={`/user/spend-summary/${username}`}>
<IconCoin className="size-4" />
<IconChartLine className="size-4" />
Spend Summary
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to={`/user/spend-history/${username}`}>
<IconHistory className="size-4" />
<IconReceipt2 className="size-4" />
Spend History
</Link>
</Button>
@@ -951,81 +903,8 @@ export function UserProfile() {
</div>
</div>
{/* Billing & Subscription Group */}
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5">
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-11.25">
<IconCreditCard className="size-4" />
Billing
</h4>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Stripe Customers
</span>
{isStripeIdsLoading ? (
<Skeleton className="h-12 w-full rounded-lg" />
) : stripeCustomers.length > 0 ? (
<div className="flex flex-col gap-2">
{stripeCustomers.map(({ id, entries }) => (
<div
key={id}
className="flex flex-col gap-1.5 p-2 bg-secondary/30 rounded-lg"
>
<div className="flex items-center gap-1.5 min-w-0">
<a
href={stripeCustomerDashboardUrl(id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 min-w-0 font-mono text-xs hover:underline"
title="Open in Stripe Dashboard"
>
<span className="truncate">{id}</span>
<IconExternalLink className="size-3 shrink-0 text-muted-foreground" />
</a>
<button
type="button"
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors cursor-pointer p-0.5"
onClick={() => copyToClipboard(id, `stripe_${id}`)}
title="Copy customer ID"
>
{copiedStates[`stripe_${id}`] ? (
<IconCheck className="size-3 text-emerald-400" />
) : (
<IconCopy className="size-3 opacity-50" />
)}
</button>
</div>
<div className="flex flex-wrap gap-1">
{entries.map((entry) => (
<Badge
key={`${entry.payments_namespace}_${entry.source}`}
variant="secondary"
className="text-[10px] py-0 px-1.5"
>
{formatNamespace(entry.payments_namespace)}
{" · "}
{STRIPE_SOURCE_LABELS[entry.source]}
</Badge>
))}
</div>
</div>
))}
{mismatchedNamespaces.length > 0 && (
<p className="flex items-center gap-1 text-[11px] text-amber-400">
<IconAlertTriangle className="size-3 shrink-0" />
Multiple customer IDs in{" "}
{mismatchedNamespaces.map(formatNamespace).join(", ")}
</p>
)}
</div>
) : (
<span className="text-sm text-muted-foreground italic">
Not Connected
</span>
)}
</div>
</div>
</div>
{/* Billing (shared component, also on the spend-summary page) */}
<UserBillingSection userToken={user.token} />
{/* Security & System Identifiers Group */}
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5">
@@ -22,6 +22,7 @@ import {
IconArrowLeft,
IconChartLine,
IconCoin,
IconReceipt2,
IconLoader2,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
@@ -171,7 +172,7 @@ export function UserSpendHistory() {
Back to profile
</Button>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconCoin className="size-6 text-muted-foreground" />
<IconReceipt2 className="size-6 text-muted-foreground" />
Spend History
</h1>
<p className="text-muted-foreground">
@@ -34,10 +34,12 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import {
IconAlertCircle,
IconArrowLeft,
IconChartLine,
IconCoin,
IconHistory,
IconReceipt2,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
import { UserBillingSection } from "@/components/UserBillingSection";
// --- formatting helpers ---
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
@@ -185,6 +187,18 @@ export function UserSpendSummary() {
[chartData],
);
// Fit the Y axis tightly to the currently-displayed data (recomputes on every
// period change), with ~10% headroom so peaks never touch/overflow the top.
const yDomain = useMemo<[number, number]>(() => {
if (chartData.length === 0) return [0, 1];
const values = chartData.map((d) => d.net);
const dataMax = Math.max(0, ...values);
const dataMin = Math.min(0, ...values);
const max = dataMax === 0 ? 1 : dataMax * 1.1;
const min = dataMin === 0 ? 0 : dataMin * 1.1;
return [min, max];
}, [chartData]);
if (isLoading) {
return (
<div className="flex flex-col gap-6">
@@ -229,7 +243,7 @@ export function UserSpendSummary() {
Back to profile
</Button>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconCoin className="size-6 text-muted-foreground" />
<IconChartLine className="size-6 text-muted-foreground" />
Spend Summary
</h1>
<p className="text-muted-foreground">
@@ -243,7 +257,7 @@ export function UserSpendSummary() {
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/user/spend-history/${username}`}>
<IconHistory className="size-4" />
<IconReceipt2 className="size-4" />
Spend History
</Link>
</Button>
@@ -309,7 +323,8 @@ export function UserSpendSummary() {
axisLine={false}
width={56}
tickMargin={8}
domain={["auto", "auto"]}
domain={yDomain}
allowDataOverflow
tickFormatter={(value) => formatUsdCompact(Number(value) * 100)}
/>
<ChartTooltip
@@ -330,10 +345,17 @@ export function UserSpendSummary() {
/>
<Area
dataKey="net"
type="natural"
type="monotone"
fill="url(#fillNet)"
stroke="var(--color-net)"
strokeWidth={2}
dot={{
r: 2.5,
fill: "var(--color-net)",
stroke: "var(--background)",
strokeWidth: 1,
}}
activeDot={{ r: 5 }}
/>
</AreaChart>
</ChartContainer>
@@ -350,6 +372,9 @@ export function UserSpendSummary() {
<p className="text-muted-foreground">No spend summary for this user.</p>
</div>
)}
{/* Billing (same Stripe block as the profile page) */}
{user && <UserBillingSection userToken={user.token} />}
</div>
);
}