Loading...
Loading...
Stripe, RevenueCat, subscription billing, webhooks, PCI compliance, and tax
npx skill4agent add travisjneuman/.claude payment-integrationpayment_intent.succeeded// Server - Create Checkout Session
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
export async function POST(req: NextRequest) {
const { priceId, userId, email } = await req.json();
// Get or create Stripe customer
let customerId = await getStripeCustomerId(userId);
if (!customerId) {
const customer = await stripe.customers.create({
email,
metadata: { userId },
});
customerId = customer.id;
await saveStripeCustomerId(userId, customerId);
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
subscription_data: {
metadata: { userId },
trial_period_days: 14,
},
// Enable automatic tax calculation
automatic_tax: { enabled: true },
// Allow promo codes
allow_promotion_codes: true,
// Collect billing address for tax
billing_address_collection: "required",
// Customer portal for self-service management
customer_update: {
address: "auto",
name: "auto",
},
});
return NextResponse.json({ url: session.url });
}// Client - Redirect to Checkout
function PricingCard({ plan }: { plan: PricingPlan }) {
const [loading, setLoading] = useState(false);
const handleSubscribe = async () => {
setLoading(true);
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
priceId: plan.stripePriceId,
userId: user.id,
email: user.email,
}),
});
const { url } = await res.json();
window.location.href = url; // Redirect to Stripe Checkout
} catch (error) {
toast.error("Failed to start checkout");
} finally {
setLoading(false);
}
};
return (
<div className="pricing-card">
<h3>{plan.name}</h3>
<p className="price">${plan.price}/mo</p>
<ul>
{plan.features.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
<button onClick={handleSubscribe} disabled={loading}>
{loading ? "Redirecting..." : "Subscribe"}
</button>
</div>
);
}// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Process each event type
const eventHandlers: Record<string, (event: Stripe.Event) => Promise<void>> = {
"checkout.session.completed": async (event) => {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId ?? session.subscription?.toString();
await db.user.update({
where: { stripeCustomerId: session.customer as string },
data: {
subscriptionId: session.subscription as string,
subscriptionStatus: "active",
},
});
},
"customer.subscription.updated": async (event) => {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
subscriptionStatus: subscription.status,
planId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
},
"customer.subscription.deleted": async (event) => {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
subscriptionStatus: "canceled",
planId: null,
},
});
},
"invoice.payment_failed": async (event) => {
const invoice = event.data.object as Stripe.Invoice;
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: "past_due" },
});
// Send dunning email
await sendDunningEmail(invoice.customer as string, {
amountDue: invoice.amount_due,
nextRetry: invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000)
: null,
});
},
"invoice.paid": async (event) => {
const invoice = event.data.object as Stripe.Invoice;
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: "active" },
});
},
};
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
// 1. Verify webhook signature (prevents spoofing)
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// 2. Idempotency check - skip already-processed events
const alreadyProcessed = await db.stripeEvent.findUnique({
where: { eventId: event.id },
});
if (alreadyProcessed) {
return NextResponse.json({ received: true });
}
// 3. Process the event
const handler = eventHandlers[event.type];
if (handler) {
try {
await handler(event);
// 4. Record processed event for idempotency
await db.stripeEvent.create({
data: {
eventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
} catch (err) {
console.error(`Error processing ${event.type}:`, err);
// Return 500 so Stripe retries
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
}
}
return NextResponse.json({ received: true });
}// Create portal session
// app/api/billing/portal/route.ts
export async function POST(req: NextRequest) {
const { userId } = await req.json();
const user = await db.user.findUniqueOrThrow({
where: { id: userId },
select: { stripeCustomerId: true },
});
if (!user.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/settings/billing`,
});
return NextResponse.json({ url: session.url });
}// Configure the portal in Stripe (do this once, via API or Dashboard)
await stripe.billingPortal.configurations.create({
business_profile: {
headline: "Manage your subscription",
},
features: {
subscription_update: {
enabled: true,
default_allowed_updates: ["price", "quantity"],
proration_behavior: "create_prorations",
products: [
{
product: "prod_xxx",
prices: ["price_monthly", "price_annual"],
},
],
},
subscription_cancel: {
enabled: true,
mode: "at_period_end", // Don't cancel immediately
cancellation_reason: {
enabled: true,
options: [
"too_expensive",
"missing_features",
"switched_service",
"unused",
"other",
],
},
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
});// Report usage to Stripe
async function reportUsage(
subscriptionItemId: string,
quantity: number,
timestamp?: number
): Promise<void> {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: timestamp ?? Math.floor(Date.now() / 1000),
action: "increment", // Add to existing usage (vs "set" to replace)
});
}
// Background job: aggregate and report usage hourly
async function reportHourlyUsage(): Promise<void> {
const activeSubscriptions = await db.subscription.findMany({
where: { status: "active", plan: { billingModel: "usage" } },
include: { user: true },
});
for (const sub of activeSubscriptions) {
const usage = await getHourlyUsage(sub.userId);
if (usage > 0) {
await reportUsage(sub.stripeSubscriptionItemId, usage);
await db.usageReport.create({
data: {
subscriptionId: sub.id,
quantity: usage,
reportedAt: new Date(),
},
});
}
}
}
// Track usage in your application
async function trackApiCall(userId: string, endpoint: string): Promise<void> {
// Increment usage counter (Redis for speed)
const key = `usage:${userId}:${getCurrentHour()}`;
await redis.incr(key);
await redis.expire(key, 86400 * 7); // Keep for 7 days
// Check if user is approaching their limit
const currentUsage = await getCurrentMonthUsage(userId);
const limit = await getUserPlanLimit(userId);
if (currentUsage >= limit * 0.8) {
await sendUsageWarningEmail(userId, currentUsage, limit);
}
if (currentUsage >= limit) {
throw new UsageLimitExceededError(userId, currentUsage, limit);
}
}| Number | Scenario |
|---|---|
| Successful payment |
| 3D Secure required |
| Declined (insufficient funds) |
| Attaching to customer fails |
| Requires authentication |
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Trusting client-side payment confirmation | Users can spoof success | Webhook handler is the source of truth |
| Handling raw card numbers on your server | PCI SAQ-D compliance (very expensive) | Use Stripe Elements or Checkout |
| No idempotency in webhook handlers | Double charges, duplicate provisioning | Deduplicate by event ID |
| Canceling subscriptions immediately | Users lose access mid-billing period | Cancel at period end ( |
| Hardcoding prices in your app | Can't change pricing without deploy | Store price IDs in database or environment |
| No dunning flow for failed payments | Silent revenue loss (involuntary churn) | Automated retry + dunning emails |
| Testing with real payment methods | Risk of real charges, no test clocks | Use Stripe test mode exclusively |
stripe listen --forward-toauthentication-patternsemail-systemsmonitoring-observabilitydocs/reference/stacks/fullstack-nextjs-nestjs.md