Stripe Billing¶
Payment integration using Stripe with BetterAuth plugin.
Configuration¶
Environment Variables¶
Stripe Client¶
lib/stripe.ts
import { loadStripe } from "@stripe/stripe-js";
export const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
BetterAuth Stripe Plugin¶
In lib/auth-client.ts:
import { stripeClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
// ...
plugins: [
stripeClient({ subscription: true }),
// ...
]
});
Payment Flow¶
1. Checkout Initiation¶
User navigates to /portal/[organizationSlug]/pay
// Create checkout session via BetterAuth
const { data: session } = await authClient.stripe.createCheckoutSession({
organizationId,
priceId: "price_xxx",
successUrl: `${window.location.origin}/portal/${orgSlug}/pay/success`,
cancelUrl: `${window.location.origin}/portal/${orgSlug}/pay`
});
// Redirect to Stripe Checkout
window.location.href = session.url;
2. Stripe Checkout¶
User completes payment on Stripe's hosted checkout page.
3. Success Redirect¶
After successful payment, user redirected to /portal/[slug]/pay/success
// pay/success/page.tsx
export default function PaymentSuccessPage() {
const router = useRouter();
const params = useParams();
useEffect(() => {
// Refresh organization data to get updated subscription
queryClient.invalidateQueries(["organization"]);
}, []);
return (
<div>
<h1>Payment Successful!</h1>
<p>Your subscription is now active.</p>
<Button onClick={() => router.push(`/portal/${params.organizationSlug}`)}>
Continue to Portal
</Button>
</div>
);
}
Embedded Payment Form¶
For inline payments without redirect.
Stripe Elements Setup¶
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "@/lib/stripe";
function PaymentPage() {
const [clientSecret, setClientSecret] = useState("");
useEffect(() => {
// Create payment intent
fetch("/api/create-payment-intent", {
method: "POST",
body: JSON.stringify({ amount: 1000 })
})
.then(res => res.json())
.then(data => setClientSecret(data.clientSecret));
}, []);
const options = {
clientSecret,
appearance: {
theme: "stripe" as const,
variables: {
colorPrimary: "#0570de"
}
}
};
return (
<Elements stripe={stripePromise} options={options}>
<StripePaymentForm />
</Elements>
);
}
Payment Form Component¶
components/forms/stripe-payment-form.tsx
import {
PaymentElement,
useStripe,
useElements
} from "@stripe/react-stripe-js";
export function StripePaymentForm() {
const stripe = useStripe();
const elements = useElements();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
setIsLoading(true);
setError(null);
const { error: submitError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/portal/${orgSlug}/pay/success`
}
});
if (submitError) {
setError(submitError.message || "Payment failed");
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
{error && <div className="text-red-500">{error}</div>}
<Button type="submit" disabled={!stripe || isLoading}>
{isLoading ? "Processing..." : "Pay Now"}
</Button>
</form>
);
}
Billing Management¶
Billing Settings Page¶
/portal/[organizationSlug]/settings/billing
export default function BillingSettingsPage() {
const { activeOrganization } = useAuth();
const openBillingPortal = async () => {
const { data } = await authClient.stripe.createBillingPortalSession({
organizationId: activeOrganization.id
});
window.location.href = data.url;
};
return (
<div>
<h1>Billing Settings</h1>
<Card>
<CardHeader>
<CardTitle>Current Plan</CardTitle>
</CardHeader>
<CardContent>
<p>Plan: {activeOrganization.subscription?.plan || "Free"}</p>
<p>Status: {activeOrganization.subscription?.status || "Inactive"}</p>
</CardContent>
</Card>
<Button onClick={openBillingPortal}>
Manage Subscription
</Button>
</div>
);
}
Billing Portal¶
Stripe's customer portal handles: - Update payment method - View invoices - Cancel subscription - Change plan
Subscription Status¶
Access subscription data via organization:
const { activeOrganization } = useAuth();
// Subscription fields (via BetterAuth Stripe plugin)
activeOrganization?.subscription?.status // active, canceled, past_due
activeOrganization?.subscription?.plan // Plan name
activeOrganization?.subscription?.currentPeriodEnd // Renewal date
Pricing Display¶
PricingCard Component¶
components/cards/pricing-card.tsx
interface PricingCardProps {
name: string;
price: number;
features: string[];
priceId: string;
isPopular?: boolean;
}
export function PricingCard({ name, price, features, priceId, isPopular }) {
const handleSelect = async () => {
// Redirect to checkout
const { data } = await authClient.stripe.createCheckoutSession({
organizationId,
priceId
});
window.location.href = data.url;
};
return (
<Card className={isPopular ? "border-primary" : ""}>
<CardHeader>
<CardTitle>{name}</CardTitle>
<div className="text-3xl font-bold">${price}/mo</div>
</CardHeader>
<CardContent>
<ul>
{features.map(f => <li key={f}>✓ {f}</li>)}
</ul>
<Button onClick={handleSelect}>
Get Started
</Button>
</CardContent>
</Card>
);
}
Error Handling¶
try {
await stripe.confirmPayment({ ... });
} catch (error) {
if (error.type === "card_error") {
setError(error.message);
} else if (error.type === "validation_error") {
setError("Please check your card details");
} else {
setError("An unexpected error occurred");
logger.error("Payment error", { error });
}
}
Webhooks¶
Stripe webhooks are handled by the BetterAuth backend:
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- invoice.paid
- invoice.payment_failed
These automatically update organization subscription status.