StarterApp Docs
Billing

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

  1. User clicks "Subscribe" button
  2. billing.checkout() creates Stripe Checkout session
  3. User completes payment on Stripe
  4. UseAutumn marks the subscription active and stores the new products
  5. StarterApp refreshes billing data and surfaces the updated plan

Renewal

  1. Stripe charges card automatically
  2. UseAutumn updates the subscription period and any usage counters
  3. StarterApp reflects the new billing window on the next data refresh
  4. No manual intervention needed

Cancellation

  1. User opens billing portal via openBillingPortal()
  2. User cancels subscription in Stripe UI
  3. UseAutumn marks the subscription as canceling (active until period end)
  4. At period end, UseAutumn moves the subscription to canceled
  5. 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 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