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/stripeGet Stripe Keys
- Create account at stripe.com
- Get API keys from Dashboard → Developers → API keys
- Add to
.env:
# .env
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # From webhooks setupCreate 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
- Go to Stripe Dashboard → Developers → Webhooks
- Add endpoint:
https://yourapp.com/api/webhooks/stripe - Select events:
checkout.session.completedcustomer.subscription.deletedcustomer.subscription.updatedinvoice.payment_failed
- 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:migratePricing 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 SecureTest 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.completedBest 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! 🚀