mobile: billing & usage in network settings (parity phase 5)

Surface the network plan, daily usage, and Stripe management — all backed
by client methods that already existed. New use-billing hooks and a
BillingSection (mirroring desktop): every member sees the plan + usage
summary; admins get cadence selection + "Upgrade to Pro" (checkout) and
"Manage subscription" (portal), opening Stripe in the system browser.
Added to NetworkSettingsScreen.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
This commit is contained in:
Claude
2026-06-21 02:05:12 +00:00
parent 9a46f39121
commit c161d96534
3 changed files with 319 additions and 0 deletions
@@ -0,0 +1,281 @@
import { useState } from 'react';
import { Linking, Pressable, Text, View } from 'react-native';
import { ExternalLink } from 'lucide-react-native';
import { toast } from 'sonner-native';
import type { BillingCadence, BillingStatus } from '@/api/types';
import {
useCreateCheckoutSession,
useCreatePortalSession,
useNetworkBilling,
useNetworkUsage,
} from '@/hooks/use-billing';
import { useIsNetworkAdmin } from '@/hooks/use-networks';
import { toUserMessage } from '@/lib/errors';
import { cn } from '@/lib/utils';
function formatCents(cents: number): string {
if (cents % 100 === 0) return `$${cents / 100}`;
return `$${(cents / 100).toFixed(2)}`;
}
function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<View className="flex-row items-center gap-3 px-1 py-2">
<Text className="text-muted-foreground text-sm">{label}</Text>
<View className="flex-1" />
<View>{value}</View>
</View>
);
}
/**
* Plan + usage summary for every member, plus admin-only upgrade/manage
* controls. Mirrors desktop's BillingSection — `/usage` powers the
* everyone-visible summary; `/billing` (admin-gated) drives the controls.
* Stripe checkout/portal URLs are opened in the system browser.
*/
export function BillingSection({ networkId }: { networkId: string }) {
const isAdmin = useIsNetworkAdmin(networkId);
const { data: usage } = useNetworkUsage(networkId);
const isPro = usage?.plan === 'pro';
return (
<View className="px-3">
<Text className="text-foreground text-base font-semibold pt-2 pb-2">
Plan &amp; billing
</Text>
<InfoRow
label="Plan"
value={
<Text className="text-foreground text-sm font-medium">
{isPro ? 'Llink Pro' : 'Llink Free'}
</Text>
}
/>
{!isPro && usage?.limit != null ? (
<InfoRow
label="Todays messages"
value={
<Text className="text-foreground text-sm tabular-nums">
{usage.used} / {usage.limit}
</Text>
}
/>
) : null}
{isAdmin ? <AdminBillingControls networkId={networkId} /> : null}
</View>
);
}
function AdminBillingControls({ networkId }: { networkId: string }) {
const {
data: billing,
isLoading,
error,
} = useNetworkBilling(networkId, true);
if (isLoading || !billing) {
return (
<Text className="text-muted-foreground text-sm px-1 py-2">
{error ? `Couldnt load billing: ${toUserMessage(error)}` : 'Loading…'}
</Text>
);
}
return billing.plan === 'pro' ? (
<ProBilling networkId={networkId} billing={billing} />
) : (
<FreeBilling networkId={networkId} billing={billing} />
);
}
function FreeBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createCheckout = useCreateCheckoutSession(networkId);
const [cadence, setCadence] = useState<BillingCadence>('annual');
const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12);
const savingsPct = Math.round(
(1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100,
);
const handleUpgrade = async () => {
try {
const { url } = await createCheckout.mutateAsync(cadence);
await Linking.openURL(url);
} catch (err) {
toast.error(toUserMessage(err));
}
};
return (
<View className="mt-2 gap-2">
<CadenceOption
label="Annual"
note="Billed annually"
perSeatCents={annualPerSeatMonthlyCents}
badge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
selected={cadence === 'annual'}
onPress={() => setCadence('annual')}
/>
<CadenceOption
label="Monthly"
note="Billed monthly · cancel anytime"
perSeatCents={billing.price_monthly_cents}
selected={cadence === 'monthly'}
onPress={() => setCadence('monthly')}
/>
<Pressable
onPress={handleUpgrade}
disabled={createCheckout.isPending}
className="mt-2 rounded-xl bg-primary py-3 items-center"
>
<Text className="text-primary-foreground text-base font-semibold">
{createCheckout.isPending ? 'Opening Stripe…' : 'Upgrade to Pro'}
</Text>
</Pressable>
</View>
);
}
function CadenceOption({
label,
note,
perSeatCents,
badge,
selected,
onPress,
}: {
label: string;
note: string;
perSeatCents: number;
badge?: string;
selected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
'flex-row items-center gap-3 rounded-xl border px-4 py-3',
selected ? 'border-primary bg-accent' : 'border-border',
)}
>
<View
className={cn(
'h-5 w-5 rounded-full border-2',
selected ? 'border-primary bg-primary' : 'border-muted-foreground',
)}
/>
<View className="flex-1">
<View className="flex-row items-center gap-2">
<Text className="text-foreground text-sm font-medium">{label}</Text>
{badge ? (
<View className="bg-primary rounded-full px-2 py-0.5">
<Text className="text-primary-foreground text-[10px] font-semibold">
{badge}
</Text>
</View>
) : null}
</View>
<Text className="text-muted-foreground text-xs">{note}</Text>
</View>
<View className="items-end">
<Text className="text-foreground text-sm font-medium">
{formatCents(perSeatCents)}
</Text>
<Text className="text-muted-foreground text-xs">per seat / mo</Text>
</View>
</Pressable>
);
}
function ProBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createPortal = useCreatePortalSession(networkId);
const cadenceLabel = billing.cadence === 'annual' ? 'Annual' : 'Monthly';
const perSeatCents =
billing.cadence === 'annual'
? Math.round(billing.price_annual_cents / 12)
: billing.price_monthly_cents;
const renewal = billing.current_period_end
? formatDate(billing.current_period_end)
: null;
const handleManage = async () => {
try {
const { url } = await createPortal.mutateAsync();
await Linking.openURL(url);
} catch (err) {
toast.error(toUserMessage(err));
}
};
return (
<View className="mt-2">
{billing.cancel_at_period_end && renewal ? (
<Text className="text-destructive text-sm py-2">
Your subscription downgrades to Free on {renewal}.
</Text>
) : null}
{billing.plan_status === 'past_due' ? (
<Text className="text-destructive text-sm py-2">
Your last payment failed. Update your payment method to keep Pro
active.
</Text>
) : null}
<InfoRow
label="Billing"
value={
<Text className="text-foreground text-sm">
{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}
</Text>
}
/>
<InfoRow
label="Seats"
value={<Text className="text-foreground text-sm">{billing.seats}</Text>}
/>
{renewal ? (
<InfoRow
label={billing.cancel_at_period_end ? 'Ends' : 'Renews'}
value={<Text className="text-foreground text-sm">{renewal}</Text>}
/>
) : null}
<Pressable
onPress={handleManage}
disabled={createPortal.isPending}
className="mt-2 flex-row items-center justify-center gap-2 rounded-xl border border-border py-3"
>
<ExternalLink color="#fafafa" size={15} />
<Text className="text-foreground text-base font-medium">
{createPortal.isPending ? 'Opening Stripe…' : 'Manage subscription'}
</Text>
</Pressable>
</View>
);
}
@@ -15,6 +15,7 @@ import { Avatar } from '@/components/Avatar';
import { toUserMessage } from '@/lib/errors';
import type { RootStackScreenProps } from '@/navigation/types';
import { AddMembersSheet } from './AddMembersSheet';
import { BillingSection } from './BillingSection';
export function NetworkSettingsScreen({
route,
@@ -157,6 +158,10 @@ export function NetworkSettingsScreen({
)}
</View>
) : null}
<View className="mt-4 border-t border-border pt-2">
<BillingSection networkId={networkId} />
</View>
</ScrollView>
{isAdmin ? (
+33
View File
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
import type { BillingCadence } from '@/api/types';
/** Plan + quota summary. Member-accessible (sourced from `/usage`). */
export function useNetworkUsage(networkId: string) {
return useQuery({
queryKey: ['network-usage', networkId],
queryFn: () => apiClient.getNetworkUsage(networkId),
});
}
/** Full billing status. Admin-gated (`/billing`). */
export function useNetworkBilling(networkId: string, enabled: boolean) {
return useQuery({
queryKey: ['network-billing', networkId],
queryFn: () => apiClient.getNetworkBilling(networkId),
enabled,
});
}
export function useCreateCheckoutSession(networkId: string) {
return useMutation({
mutationFn: (cadence: BillingCadence) =>
apiClient.createCheckoutSession(networkId, cadence),
});
}
export function useCreatePortalSession(networkId: string) {
return useMutation({
mutationFn: () => apiClient.createPortalSession(networkId),
});
}