Frontend Billing UI and Upgrade Flow
Frontend Billing UI and Upgrade Flow
The Feature
An organizer on the free tier sees their current plan status, a clear comparison of free vs paid features, and an “Upgrade” button. Clicking “Upgrade” redirects to Stripe Checkout. After payment, they return to the settings page with a success message and the paid tier is active. A paid organizer sees “Manage subscription” which opens the Stripe customer portal.
The Decision
The billing UI is minimal. A plan comparison, an upgrade button, and a portal link. No custom invoice display. No custom payment method management. Stripe’s customer portal handles plan changes, payment method updates, and invoice history. Building custom UI for these features duplicates what Stripe provides for free and creates a maintenance burden as Stripe’s features evolve.
The Implementation
Settings Page with Billing
// frontend/src/pages/dashboard/SettingsPage.tsx
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import { useGetBillingStatus, useCreateCheckoutSession, useCreatePortalSession } from "@/api/generated";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import { useEffect } from "react";
export function SettingsPage() {
const [searchParams] = useSearchParams();
const { toast } = useToast();
const { data: billing, isLoading } = useGetBillingStatus();
const createCheckout = useCreateCheckoutSession();
const createPortal = useCreatePortalSession();
// Handle return from Stripe checkout
useEffect(() => {
const billingParam = searchParams.get("billing");
if (billingParam === "success") {
toast({
title: "Upgrade successful",
description: "Your account has been upgraded to the Pro plan.",
});
} else if (billingParam === "cancelled") {
toast({
title: "Upgrade cancelled",
description: "No changes were made to your plan.",
});
}
}, [searchParams, toast]);
const handleUpgrade = async () => {
try {
const { checkout_url } = await createCheckout.mutateAsync({});
window.location.href = checkout_url;
} catch {
toast({
title: "Failed to start upgrade",
description: "Please try again.",
variant: "destructive",
});
}
};
const handleManageSubscription = async () => {
try {
const { portal_url } = await createPortal.mutateAsync({});
window.location.href = portal_url;
} catch {
toast({
title: "Failed to open billing portal",
variant: "destructive",
});
}
};
if (isLoading) return <div>Loading...</div>;
const isPaid = billing?.subscription_tier === "paid";
return (
<div className="space-y-6 max-w-2xl">
<h1 className="text-2xl font-bold">Settings</h1>
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Subscription
<Badge variant={isPaid ? "default" : "secondary"}>
{isPaid ? "Pro" : "Free"}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{isPaid ? (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
You are on the Pro plan. Unlimited vendors, multiple markets,
and all premium features are active.
</p>
<Button variant="outline" onClick={handleManageSubscription}>
Manage Subscription
</Button>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<h3 className="font-semibold">Free</h3>
<ul className="text-sm text-muted-foreground space-y-1">
<li>1 market</li>
<li>Up to 20 active vendors</li>
<li>Unlimited market days</li>
<li>Email notifications</li>
</ul>
</div>
<div className="space-y-2 p-4 border rounded-lg bg-muted/50">
<h3 className="font-semibold">Pro — $29/month</h3>
<ul className="text-sm space-y-1">
<li>Unlimited vendors</li>
<li>Multiple markets</li>
<li>Vendor fee collection</li>
<li>Document storage</li>
<li>CSV reports</li>
</ul>
</div>
</div>
<Button onClick={handleUpgrade}>
Upgrade to Pro
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
Billing Status Endpoint
# backend/app/routers/billing.py (addition)
@router.get("/status")
async def get_billing_status(
organizer: OrganizerProfile = Depends(get_organizer_profile),
) -> dict:
return {
"subscription_tier": organizer.subscription_tier.value,
"has_payment_method": organizer.stripe_customer_id is not None,
}
The Trap
// TRAP: Updating local state before Stripe confirms
const handleUpgrade = async () => {
// Don't do this: changing the tier locally before Stripe processes payment
await updateLocalTier("paid"); // User sees paid features
const { checkout_url } = await createCheckout.mutateAsync({});
window.location.href = checkout_url;
// If user closes the checkout page, they have paid features without paying
};
// SAFE: Only Stripe webhooks change the tier
const handleUpgrade = async () => {
const { checkout_url } = await createCheckout.mutateAsync({});
window.location.href = checkout_url;
// Tier changes only when checkout.session.completed webhook fires
// On return, the frontend refetches billing status from the backend
};
The subscription tier changes only when the Stripe webhook confirms the payment. The frontend never writes to the subscription tier. It reads the tier from the backend, which is updated by webhooks. This prevents the user from accessing paid features before payment is confirmed.
The Cost
The Stripe integration itself has no additional infrastructure cost. The developer time is the primary investment: approximately 1-2 days for the complete checkout flow, webhook handling, and frontend UI. Stripe’s 2.9% + $0.30 per transaction is the ongoing operational cost, paid from revenue.