GameCraftGameCraft

Payments & Billing

Add payment processing and subscription billing to your ProductReady app

Payments & Billing

Add payment processing and subscription billing to your ProductReady app. This guide covers:

  • ✅ One-time payments
  • ✅ Recurring subscriptions
  • ✅ Usage-based billing
  • ✅ Webhooks for payment events
  • ✅ Customer portal

ProductReady uses Creem by default - a Merchant of Record that handles taxes and compliance. See the Billing documentation for the full multi-provider setup. This guide shows how to add Stripe as an alternative.


Quick Setup

Install Stripe

pnpm add stripe @stripe/stripe-js
pnpm add -D @types/stripe

Get Stripe Keys

  1. Create account at stripe.com
  2. Get API keys from Dashboard → Developers → API keys
  3. Add to .env:
# .env
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # From webhooks setup

Create Stripe Client

Create src/lib/stripe.ts:

import Stripe from 'stripe';

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error('Missing STRIPE_SECRET_KEY');
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2024-11-20.acacia',
  typescript: true,
});

One-Time Payments

Create Checkout Session

Add tRPC endpoint for creating checkout:

// src/server/routers/payments.ts
import { stripe } from '~/lib/stripe';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { z } from 'zod';

export const paymentsRouter = createTRPCRouter({
  createCheckout: protectedProcedure
    .input(z.object({
      priceId: z.string(), // Stripe Price ID
      successUrl: z.string().url(),
      cancelUrl: z.string().url(),
    }))
    .mutation(async ({ ctx, input }) => {
      const session = await stripe.checkout.sessions.create({
        customer_email: ctx.session.user.email,
        line_items: [
          {
            price: input.priceId,
            quantity: 1,
          },
        ],
        mode: 'payment',
        success_url: input.successUrl,
        cancel_url: input.cancelUrl,
        metadata: {
          userId: ctx.session.user.id,
        },
      });

      return { url: session.url };
    }),
});

Register router in src/server/routers/index.ts:

import { paymentsRouter } from './payments';

export const appRouter = createTRPCRouter({
  agentTasks: agentTasksRouter,
  posts: postsRouter,
  payments: paymentsRouter, // ← Add this
});

Use in Frontend

'use client';

import { trpc } from '~/lib/trpc/client';
import { Button } from 'kui/button';

export function BuyButton({ priceId }: { priceId: string }) {
  const createCheckout = trpc.payments.createCheckout.useMutation();

  async function handlePurchase() {
    const { url } = await createCheckout.mutateAsync({
      priceId,
      successUrl: `${window.location.origin}/success`,
      cancelUrl: `${window.location.origin}/pricing`,
    });

    if (url) {
      window.location.href = url;
    }
  }

  return (
    <Button onClick={handlePurchase} disabled={createCheckout.isPending}>
      {createCheckout.isPending ? 'Loading...' : 'Buy Now'}
    </Button>
  );
}

Subscription Billing

Create Subscription Plans

Create products in Stripe Dashboard or via API:

// Create product (one-time setup)
const product = await stripe.products.create({
  name: 'Pro Plan',
  description: 'Full access to all features',
});

// Create price
const price = await stripe.prices.create({
  product: product.id,
  unit_amount: 2900, // $29.00
  currency: 'usd',
  recurring: {
    interval: 'month',
  },
});

Subscribe User

// src/server/routers/subscriptions.ts
export const subscriptionsRouter = createTRPCRouter({
  subscribe: protectedProcedure
    .input(z.object({
      priceId: z.string(),
    }))
    .mutation(async ({ ctx, input }) => {
      // Create or get Stripe customer
      let customerId = ctx.session.user.stripeCustomerId;
      
      if (!customerId) {
        const customer = await stripe.customers.create({
          email: ctx.session.user.email,
          metadata: {
            userId: ctx.session.user.id,
          },
        });
        
        customerId = customer.id;
        
        // Save to database
        await ctx.db
          .update(users)
          .set({ stripeCustomerId: customerId })
          .where(eq(users.id, ctx.session.user.id));
      }

      // Create checkout session for subscription
      const session = await stripe.checkout.sessions.create({
        customer: customerId,
        line_items: [{ price: input.priceId, quantity: 1 }],
        mode: 'subscription',
        success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?subscribed=true`,
        cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
      });

      return { url: session.url };
    }),

  // Get current subscription
  getSubscription: protectedProcedure.query(async ({ ctx }) => {
    const customerId = ctx.session.user.stripeCustomerId;
    if (!customerId) return null;

    const subscriptions = await stripe.subscriptions.list({
      customer: customerId,
      status: 'active',
      limit: 1,
    });

    return subscriptions.data[0] || null;
  }),

  // Cancel subscription
  cancel: protectedProcedure.mutation(async ({ ctx }) => {
    const customerId = ctx.session.user.stripeCustomerId;
    if (!customerId) throw new Error('No customer ID');

    const subscriptions = await stripe.subscriptions.list({
      customer: customerId,
      status: 'active',
    });

    if (subscriptions.data[0]) {
      await stripe.subscriptions.cancel(subscriptions.data[0].id);
    }

    return { success: true };
  }),
});

Webhooks

Handle Stripe events server-side:

Setup Webhook Endpoint

Create src/app/api/webhooks/stripe/route.ts:

import { headers } from 'next/headers';
import { stripe } from '~/lib/stripe';
import { db } from '~/db';
import { users } from '~/db/schema';
import { eq } from 'drizzle-orm';
import type Stripe from 'stripe';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get('stripe-signature');

  if (!signature) {
    return new Response('No signature', { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return new Response(`Webhook Error: ${err.message}`, { status: 400 });
  }

  // Handle events
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      
      // Update user subscription status
      if (session.mode === 'subscription') {
        await db
          .update(users)
          .set({
            subscriptionStatus: 'active',
            subscriptionId: session.subscription as string,
          })
          .where(eq(users.stripeCustomerId, session.customer as string));
      }
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      
      await db
        .update(users)
        .set({
          subscriptionStatus: 'cancelled',
        })
        .where(eq(users.stripeCustomerId, subscription.customer as string));
      break;
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      
      await db
        .update(users)
        .set({
          subscriptionStatus: subscription.status,
        })
        .where(eq(users.stripeCustomerId, subscription.customer as string));
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      
      // Send email notification or handle failed payment
      console.error('Payment failed:', invoice.id);
      break;
    }
  }

  return new Response(JSON.stringify({ received: true }), { status: 200 });
}

Configure Webhook in Stripe

  1. Go to Stripe Dashboard → Developers → Webhooks
  2. Add endpoint: https://yourapp.com/api/webhooks/stripe
  3. Select events:
    • checkout.session.completed
    • customer.subscription.deleted
    • customer.subscription.updated
    • invoice.payment_failed
  4. Copy webhook signing secret to .env

Customer Portal

Let users manage their subscription:

export const subscriptionsRouter = createTRPCRouter({
  createPortalSession: protectedProcedure.mutation(async ({ ctx }) => {
    const customerId = ctx.session.user.stripeCustomerId;
    if (!customerId) throw new Error('No customer ID');

    const session = await stripe.billingPortal.sessions.create({
      customer: customerId,
      return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing`,
    });

    return { url: session.url };
  }),
});

Use in component:

export function ManageBillingButton() {
  const createPortal = trpc.subscriptions.createPortalSession.useMutation();

  async function handleClick() {
    const { url } = await createPortal.mutateAsync();
    window.location.href = url;
  }

  return (
    <Button onClick={handleClick}>
      Manage Subscription
    </Button>
  );
}

Database Schema

Add billing fields to users table:

// src/db/schema/users.ts
export const users = pgTable('users', {
  // ... existing fields ...
  
  // Stripe fields
  stripeCustomerId: text('stripe_customer_id'),
  subscriptionId: text('subscription_id'),
  subscriptionStatus: text('subscription_status')
    .$type<'active' | 'cancelled' | 'past_due' | 'trialing'>(),
  subscriptionPriceId: text('subscription_price_id'),
  subscriptionCurrentPeriodEnd: timestamp('subscription_current_period_end'),
});

Run migration:

pnpm db:generate
pnpm db:migrate

Pricing Page Example

// src/app/(marketing)/pricing/page.tsx
'use client';

import { trpc } from '~/lib/trpc/client';
import { Button } from 'kui/button';
import { Card } from 'kui/card';

const plans = [
  {
    name: 'Starter',
    price: '$9',
    priceId: 'price_starter_monthly',
    features: ['10 agent tasks/month', 'Basic support', '1 team member'],
  },
  {
    name: 'Pro',
    price: '$29',
    priceId: 'price_pro_monthly',
    features: ['100 agent tasks/month', 'Priority support', '5 team members'],
  },
  {
    name: 'Enterprise',
    price: '$99',
    priceId: 'price_enterprise_monthly',
    features: ['Unlimited agent tasks', '24/7 support', 'Unlimited team'],
  },
];

export default function PricingPage() {
  const subscribe = trpc.subscriptions.subscribe.useMutation();

  async function handleSubscribe(priceId: string) {
    const { url } = await subscribe.mutateAsync({ priceId });
    if (url) window.location.href = url;
  }

  return (
    <div className="container mx-auto py-12">
      <h1 className="text-4xl font-bold text-center mb-12">Pricing</h1>
      
      <div className="grid md:grid-cols-3 gap-8">
        {plans.map((plan) => (
          <Card key={plan.name} className="p-6">
            <h3 className="text-2xl font-bold">{plan.name}</h3>
            <p className="text-4xl font-bold my-4">{plan.price}<span className="text-sm">/mo</span></p>
            
            <ul className="space-y-2 mb-6">
              {plan.features.map((feature) => (
                <li key={feature}>✓ {feature}</li>
              ))}
            </ul>
            
            <Button
              onClick={() => handleSubscribe(plan.priceId)}
              className="w-full"
            >
              Subscribe
            </Button>
          </Card>
        ))}
      </div>
    </div>
  );
}

Feature Gates

Limit features based on subscription:

// src/lib/subscription.ts
export function canUseFeature(user: User, feature: string): boolean {
  if (!user.subscriptionStatus || user.subscriptionStatus === 'cancelled') {
    return false; // Free tier
  }

  const limits = {
    starter: {
      agentTasks: 10,
      teamMembers: 1,
    },
    pro: {
      agentTasks: 100,
      teamMembers: 5,
    },
    enterprise: {
      agentTasks: Infinity,
      teamMembers: Infinity,
    },
  };

  // Check based on subscription tier
  return true; // Implement your logic
}

Use in tRPC:

export const agentTasksRouter = createTRPCRouter({
  create: protectedProcedure
    .input(insertAgentTaskSchema)
    .mutation(async ({ ctx, input }) => {
      // Check subscription limits
      if (!canUseFeature(ctx.session.user, 'createAgentTask')) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Upgrade to create more agent tasks',
        });
      }

      // Create agent task...
    }),
});

Testing

Use Stripe test mode:

# Test card numbers
4242 4242 4242 4242  # Success
4000 0000 0000 0002  # Declined
4000 0025 0000 3155  # 3D Secure

Test webhooks locally:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Forward webhooks to local
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger test events
stripe trigger checkout.session.completed

Best Practices

✅ Do

  • Use webhooks - Don't rely on client-side success callbacks
  • Store customer ID - Save Stripe customer ID in your database
  • Handle failures - Gracefully handle declined payments
  • Test thoroughly - Use test mode and test cards
  • Secure webhooks - Verify webhook signatures
  • Show loading states - Checkout can take a few seconds

❌ Don't

  • Don't trust client - Verify payments server-side
  • Don't expose secret key - Never send to frontend
  • Don't skip webhooks - They're critical for reliability
  • Don't ignore errors - Log and handle payment failures
  • Don't hardcode prices - Use Stripe Price IDs

Next Steps


Ready to monetize your SaaS! 🚀

On this page