General
Billing

Check Purchases & Subscriptions

Learn how to check for purchases and subscriptions to provide access to premium features.

One of the most common use cases for billing is to provide access to premium features based on a user's or organization's subscription status.

Plan IDs

Plan IDs are defined by the keys in your billing.config.ts file. For example, if you have:

config/billing.config.ts
export const billingConfig = {
  plans: {
    free: { isFree: true },
    pro: {
      /* ... */
    },
    lifetime: {
      /* ... */
    }
  }
};

The plan IDs would be "free", "pro" and "lifetime".

Client-Side Checks

Use tRPC queries to check for purchases and subscriptions on the client:

components/premium-feature.tsx
'use client';

import { trpc } from '@/trpc/client';

export function PremiumFeature() {
  const { data: billingStatus } =
    trpc.organization.subscription.getStatus.useQuery();

  if (!billingStatus?.activePlan) {
    return <div>Please subscribe to access this feature</div>;
  }

  // Check if user has a specific plan
  const hasProPlan = billingStatus.activePlan.planId === 'pro';
  const hasLifetimeAccess = billingStatus.activePlan.planId === 'lifetime';
  const hasActiveSubscription =
    billingStatus.subscription?.status === 'active' ||
    billingStatus.subscription?.status === 'trialing';

  if (!hasActiveSubscription && !hasLifetimeAccess) {
    return <div>Please subscribe to access this feature</div>;
  }

  return <div>Premium feature content</div>;
}

Billing Status Properties

The getStatus query returns:

  • activePlan: The currently active plan (if any), containing planId, planName, status, etc.
  • subscription: The active subscription object (if any), containing status, currentPeriodEnd, etc.
  • enabled: Whether billing is enabled

Server-Side Checks

In tRPC Procedures

trpc/routers/premium-feature.ts
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';

import {
  getActivePlanForOrganization,
  hasActivePaidPlan,
  hasSpecificPlan
} from '@/lib/billing';

export const premiumFeatureRouter = createTRPCRouter({
  access: protectedOrganizationProcedure.query(async ({ ctx }) => {
    const activePlan = await getActivePlanForOrganization(ctx.organization.id);

    if (
      !activePlan ||
      (activePlan.planId === 'free' && !activePlan.isLifetime)
    ) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'This feature requires an active subscription'
      });
    }

    return { data: 'premium content' };
  })
});

In Server Components

app/(saas)/dashboard/premium/page.tsx
import { getSession } from "@/lib/auth";
import { getActivePlanForOrganization } from "@/lib/billing";
import { redirect } from "next/navigation";

export default async function PremiumPage() {
  const session = await getSession();

  if (!session?.session.activeOrganizationId) {
    redirect("/auth/sign-in");
  }

  const activePlan = await getActivePlanForOrganization(session.session.activeOrganizationId);

  if (!activePlan || (activePlan.planId === 'free' && !activePlan.isLifetime)) {
    redirect("/dashboard/choose-plan");
  }

  return <div>Premium page content</div>;
}

Organization-Based Checks

Billing is organization-based by default. All checks use the organization ID from the context:

Client-Side

components/organization-feature.tsx
'use client';

import { trpc } from '@/trpc/client';

export function OrganizationFeature() {
  const { data: billingStatus } =
    trpc.organization.subscription.getStatus.useQuery();

  if (
    !billingStatus?.subscription ||
    (billingStatus.subscription.status !== 'active' &&
      billingStatus.subscription.status !== 'trialing')
  ) {
    return <div>This organization needs an active subscription</div>;
  }

  return <div>Organization premium feature</div>;
}

Server-Side

trpc/routers/organization-feature.ts
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';

import { hasActivePaidPlan } from '@/lib/billing';

export const organizationFeatureRouter = createTRPCRouter({
  access: protectedOrganizationProcedure.query(async ({ ctx }) => {
    const hasActivePlan = await hasActivePaidPlan(ctx.organization.id);

    if (!hasActivePlan) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'This organization requires an active subscription'
      });
    }

    return { data: 'organization premium content' };
  })
});

Plan Limits

You can also check plan limits:

lib/billing/check-limits.ts
import { getOrganizationPlanLimits } from '@/lib/billing/guards';

export async function checkMemberLimit(organizationId: string) {
  const limits = await getOrganizationPlanLimits(organizationId);

  // -1 means unlimited
  if (limits.maxMembers === -1) {
    return true; // Unlimited members
  }

  // Check current member count against limit
  const currentMembers = await getMemberCount(organizationId);
  return currentMembers < limits.maxMembers;
}

Helper Functions

Use the built-in guard functions for common checks:

lib/billing/feature-guards.ts
import { TRPCError } from '@trpc/server';

import {
  hasActivePaidPlan,
  hasSpecificPlan,
  requirePaidPlan,
  requireSpecificPlan
} from '@/lib/billing';

// Require any paid plan
export async function requireActivePlan(organizationId: string) {
  await requirePaidPlan(organizationId);
}

// Require a specific plan
export async function requirePlan(organizationId: string, planIds: string[]) {
  await requireSpecificPlan(organizationId, planIds);
}

// Check if has active plan (doesn't throw)
export async function checkHasActivePlan(
  organizationId: string
): Promise<boolean> {
  return hasActivePaidPlan(organizationId);
}

// Check if has specific plan (doesn't throw)
export async function checkHasPlan(
  organizationId: string,
  planId: string
): Promise<boolean> {
  return hasSpecificPlan(organizationId, planId);
}