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 prioritySupport = feature({
id: "priority_support",
name: "Priority Support",
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,
}),
// $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,
}),
// Priority support
featureItem({
feature_id: prioritySupport.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
- Email support
Premium - $25/month
- Everything in Paid
- 50,000 API calls/month
- Priority support
- Advanced analytics
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 {
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`,
});
} 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 { BILLING_PLAN_DETAILS, BILLING_PRODUCT_IDS } from "@workspace/billing/constants";
import { useServices } from "~/lib/providers/services-context";
export function PricingTable() {
const { billing } = useServices();
const customer = billing.useCustomer();
async function handleSubscribe(productId: string) {
await billing.checkout({
productId,
successUrl: window.location.origin + '/dashboard/billing',
});
}
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 { useServices } from '~/lib/providers/services-context';
import { Button } from '@workspace/ui/components/button';
export function ManageSubscriptionButton() {
const { billing } = useServices();
const customer = billing.useCustomer();
async function openPortal() {
try {
await customer.openBillingPortal({
returnUrl: window.location.origin + '/dashboard/billing',
});
} 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 { useServices } from "~/lib/providers/services-context";
export function SubscriptionCard() {
const { billing } = useServices();
const { customer, loading } = billing.useCustomer();
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 session = await getCurrentSession();
if (!session) 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
</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, Priority support
</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
- Stripe webhook notifies UseAutumn
- Subscription activates
- Convex updates subscription data
- UI updates instantly via reactivity
Renewal
- Stripe charges card automatically
- Webhook notifies UseAutumn
- Usage counters reset (e.g., API calls back to 0)
- Subscription period extends
- No manual intervention needed
Cancellation
- User opens billing portal via
openBillingPortal()
- User cancels subscription in Stripe UI
- Webhook notifies UseAutumn
- Subscription marked as
canceling
(active until period end) - At period end, subscription moves to
canceled
- User loses access to paid features
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 digits
This 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 digits
This 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