Subscriptions
Recurring billing with subscription tiers
UseAutumn manages recurring subscriptions over Stripe's billing infrastructure. The integration provides real-time subscription state, automatic checkout flows, and secure payment processing through Convex.
Pro Tip
No "free" product exists in Autumn. Users without an active subscription default to the free tier with base features.
Product Configuration
Define subscription tiers in autumn.config.ts:
import { feature, product, featureItem, priceItem } from "atmn";
import { BILLING_PRODUCT_IDS } from "@workspace/billing/constants";
// Features
export const dashboard = feature({
id: "dashboard",
name: "Dashboard Access",
type: "boolean",
});
export const apiCalls = feature({
id: "api_calls",
name: "API Calls",
type: "single_use",
});
export const usageInsights = feature({
id: "usage_insights",
name: "Usage Insights & Alerts",
type: "boolean",
});
export const analyticsSuite = feature({
id: "analytics_suite",
name: "Advanced Analytics Suite",
type: "boolean",
});
// Paid Tier: $10/month
export const paid = product({
id: BILLING_PRODUCT_IDS.paid, // "starterapp-demo-paid"
name: "Paid",
items: [
// 10,000 API calls per month
featureItem({
feature_id: apiCalls.id,
included_usage: 10_000,
interval: "month",
}),
// Dashboard access
featureItem({
feature_id: dashboard.id,
included_usage: 1,
}),
// Usage insights & alerts
featureItem({
feature_id: usageInsights.id,
included_usage: 1,
}),
// $10 per month pricing
priceItem({
price: 10,
interval: "month",
}),
],
});
// Premium Tier: $25/month
export const premium = product({
id: BILLING_PRODUCT_IDS.premium, // "starterapp-demo-premium"
name: "Premium",
items: [
// 50,000 API calls per month
featureItem({
feature_id: apiCalls.id,
included_usage: 50_000,
interval: "month",
}),
// Dashboard access
featureItem({
feature_id: dashboard.id,
included_usage: 1,
}),
// Usage insights & alerts
featureItem({
feature_id: usageInsights.id,
included_usage: 1,
}),
// Advanced analytics suite
featureItem({
feature_id: analyticsSuite.id,
included_usage: 1,
}),
// $25 per month pricing
priceItem({
price: 25,
interval: "month",
}),
],
});
After defining products, push the configuration:
npx atmn push
Always Push After Changes
Subscription products won't be available in Stripe until you push the configuration. Run npx atmn push after every pricing change.
Pricing Tiers
This codebase implements 3 tiers:
Free (No Subscription)
- No payment required
- Base functionality only
- No dashboard access
- No API calls included
Paid - $10/month
- Dashboard access
- 10,000 API calls/month
- Usage insights & alerts
Premium - $25/month
- Everything in Paid
- 50,000 API calls/month
- Advanced analytics suite
- Automated usage reports
Checkout Flow
Client-Side Checkout
Trigger checkout from React components using the billing service:
'use client';
import { useState } from 'react';
import { BILLING_PRODUCT_IDS } from '@workspace/billing/constants';
import { useServices } from '~/lib/providers/services-context';
import { Button } from '@workspace/ui/components/button';
export function UpgradeButton({ tier }: { tier: 'paid' | 'premium' }) {
const { billing } = useServices();
const [isLoading, setIsLoading] = useState(false);
async function handleUpgrade() {
setIsLoading(true);
try {
const result = await billing.checkout({
productId: tier === 'paid' ? BILLING_PRODUCT_IDS.paid : BILLING_PRODUCT_IDS.premium,
successUrl: `${window.location.origin}/dashboard/billing?success=true`,
cancelUrl: `${window.location.origin}/pricing`,
});
if (result.error) {
throw new Error(result.error.message);
}
if (result.url) {
window.location.assign(result.url);
}
} catch (error) {
console.error('Checkout failed:', error);
} finally {
setIsLoading(false);
}
}
return (
<Button onClick={handleUpgrade} disabled={isLoading}>
{isLoading ? 'Loading...' : `Upgrade to ${tier}`}
</Button>
);
}
Pricing Table Component
Display all tiers using the pre-configured plan details:
'use client';
import { useBillingService } from "@workspace/app-shell/lib/hooks/use-billing";
import { BILLING_PLAN_DETAILS, BILLING_PRODUCT_IDS } from "@workspace/billing/constants";
export function PricingTable() {
const { service: billing, placeholder } = useBillingService();
const customer = billing.useCustomer();
if (placeholder) {
return null;
}
async function handleSubscribe(productId: string) {
const result = await billing.checkout({
productId,
successUrl: window.location.origin + '/dashboard/billing',
});
if (result.error) {
throw new Error(result.error.message);
}
if (result.url) {
window.location.assign(result.url);
}
}
return (
<div className="grid gap-8 md:grid-cols-2">
{BILLING_PLAN_DETAILS.map((plan) => {
const isCurrentPlan = customer.customer?.products?.some(
(p) => p.product_id === plan.id
);
return (
<div key={plan.id} className="rounded-lg border p-6">
<h3 className="text-2xl font-bold">{plan.name}</h3>
<p className="text-muted-foreground">{plan.description}</p>
<div className="my-4">
<span className="text-4xl font-bold">{plan.price.primaryText}</span>
<span className="text-muted-foreground"> /{plan.price.secondaryText}</span>
</div>
<button
onClick={() => handleSubscribe(plan.id)}
disabled={isCurrentPlan || customer.loading}
className="w-full rounded-md bg-primary px-4 py-2"
>
{isCurrentPlan ? 'Current Plan' : plan.buttonText}
</button>
</div>
);
})}
</div>
);
}
Subscription Management
Billing Portal
Let users manage their subscriptions (update payment method, cancel, view invoices) through Stripe's hosted portal:
'use client';
import { useBillingService } from '@workspace/app-shell/lib/hooks/use-billing';
import { Button } from '@workspace/ui/components/button';
export function ManageSubscriptionButton() {
const { service: billing, placeholder } = useBillingService();
const customer = billing.useCustomer();
if (placeholder) {
return null;
}
async function openPortal() {
try {
const result = await customer.openBillingPortal({
returnUrl: window.location.origin + '/dashboard/billing',
});
if (result?.error) {
throw new Error(result.error.message);
}
if (result?.url) {
window.location.assign(result.url);
}
} catch (error) {
console.error('Failed to open billing portal:', error);
}
}
return (
<Button
onClick={openPortal}
disabled={customer.loading}
variant="outline"
>
{customer.loading ? 'Loading...' : 'Manage Subscription'}
</Button>
);
}
Billing Portal Features
The Stripe billing portal provides:
- Update payment method
- Cancel subscription
- Download invoices
- View payment history
- Update billing address
All handled by Stripe—no custom implementation needed.
Subscription Status
Display current subscription details using UseAutumn's customer state:
'use client';
import { Badge } from "@workspace/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/card";
import { useBillingService } from "@workspace/app-shell/lib/hooks/use-billing";
export function SubscriptionCard() {
const { service: billing, placeholder } = useBillingService();
const { customer, loading } = billing.useCustomer();
if (placeholder) {
return null;
}
if (loading) {
return <Card><CardContent className="p-6">Loading...</CardContent></Card>;
}
const hasSubscription = customer?.products && customer.products.length > 0;
const activeProduct = customer?.products?.[0];
if (!hasSubscription) {
return (
<Card>
<CardHeader>
<CardTitle>Free Plan</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Upgrade to unlock more features
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{activeProduct?.name || 'Subscription'}</CardTitle>
<Badge variant={activeProduct?.status === 'active' ? 'default' : 'secondary'}>
{activeProduct?.status || 'active'}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div>
<span className="text-sm text-muted-foreground">Status:</span>
<span className="ml-2 font-medium">{activeProduct?.status || 'active'}</span>
</div>
{activeProduct?.current_period_end && (
<div>
<span className="text-sm text-muted-foreground">Renews:</span>
<span className="ml-2 font-medium">
{new Date(activeProduct.current_period_end * 1000).toLocaleDateString()}
</span>
</div>
)}
</div>
</CardContent>
</Card>
);
}
Server-Side Feature Gating
Gate features by subscription using ctx.check() in Convex actions:
import { userAction } from "./_helpers/builders";
import { AppError } from "./_helpers/errors";
export const accessDashboard = userAction({
args: {},
handler: async (ctx) => {
// Check if user has dashboard access
const result = await ctx.check({ featureId: "dashboard" });
if (!result.data?.allowed) {
throw new AppError("PLAN_REQUIRED", "Upgrade to access dashboard");
}
// User has access, return dashboard data
return { allowed: true };
},
});
Then call this from your Next.js layout or page:
export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
import { api } from "@convex/_generated/api";
import { createAuth } from "@convex/lib/auth";
import { getToken } from "@convex-dev/better-auth/nextjs";
import { fetchAction } from "convex/nextjs";
import { redirect } from "next/navigation";
import { getCurrentSession } from "@workspace/app-shell/lib/auth/server";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = await getCurrentSession();
if (!user) redirect("/sign-in");
const token = await getToken(createAuth as Parameters<typeof getToken>[0]);
if (!token) redirect("/sign-in");
try {
await fetchAction(api.dashboard.accessDashboard, {}, { token });
} catch (error) {
// User doesn't have dashboard access
redirect("/pricing?upgrade=dashboard");
}
return <>{children}</>;
}
Upgrade/Downgrade Flow
Users can change plans by subscribing to a different product:
'use client';
import { BILLING_PRODUCT_IDS } from "@workspace/billing/constants";
import { useServices } from "~/lib/providers/services-context";
export function PlanSwitcher() {
const { billing } = useServices();
const { customer } = billing.useCustomer();
const currentProductId = customer?.products?.[0]?.product_id;
async function switchPlan(productId: string) {
if (currentProductId === productId) return;
await billing.checkout({
productId,
successUrl: window.location.origin + '/dashboard/billing',
});
}
return (
<div className="space-y-4">
<h3 className="font-semibold">Switch Plan</h3>
<div className="grid gap-4 md:grid-cols-2">
<button
onClick={() => switchPlan(BILLING_PRODUCT_IDS.paid)}
disabled={currentProductId === BILLING_PRODUCT_IDS.paid}
className="rounded-lg border p-4 hover:border-primary disabled:opacity-50"
>
<h4 className="font-medium">Paid - $10/month</h4>
<p className="text-sm text-muted-foreground">
10,000 API calls, Dashboard access, Usage insights
</p>
</button>
<button
onClick={() => switchPlan(BILLING_PRODUCT_IDS.premium)}
disabled={currentProductId === BILLING_PRODUCT_IDS.premium}
className="rounded-lg border p-4 hover:border-primary disabled:opacity-50"
>
<h4 className="font-medium">Premium - $25/month</h4>
<p className="text-sm text-muted-foreground">
50,000 API calls, Advanced analytics
</p>
</button>
</div>
<p className="text-xs text-muted-foreground">
Plan changes take effect immediately. Stripe handles prorations automatically.
</p>
</div>
);
}
Subscription Lifecycle
New Subscription
- User clicks "Subscribe" button
billing.checkout()creates Stripe Checkout session- User completes payment on Stripe
- UseAutumn marks the subscription active and stores the new products
- StarterApp refreshes billing data and surfaces the updated plan
Renewal
- Stripe charges card automatically
- UseAutumn updates the subscription period and any usage counters
- StarterApp reflects the new billing window on the next data refresh
- No manual intervention needed
Cancellation
- User opens billing portal via
openBillingPortal() - User cancels subscription in Stripe UI
- UseAutumn marks the subscription as canceling (active until period end)
- At period end, UseAutumn moves the subscription to
canceled - StarterApp removes paid access the next time billing data refreshes
Testing Subscriptions
Test subscription flows in development mode using Stripe test cards:
Card Number: 4242 4242 4242 4242
Expiry: Any future date (e.g., 12/34)
CVC: Any 3 digits (e.g., 123)
ZIP: Any 5 digits (e.g., 12345)This card always succeeds. Use to test the happy path.
Card Number: 4000 0000 0000 0002
Expiry: Any future date
CVC: Any 3 digits
ZIP: Any 5 digitsThis card always declines. Use to test error handling.
Card Number: 4000 0025 0000 3155
Expiry: Any future date
CVC: Any 3 digits
ZIP: Any 5 digitsThis card requires 3D Secure authentication. Use to test Strong Customer Authentication (SCA) flows.
Heads up
Never use test cards in production. Stripe automatically detects the environment and only accepts test cards in test mode.
Troubleshooting
Related Documentation
- Feature Gating - Control access by subscription tier
- Usage-Based Billing - Track and limit usage
- Overview - Configuration and architecture