Building a SaaS is rarely just about the core feature. For us at ClipCrafter—an AI-powered video editor—the "video" part was actually one of our easier challenges. The real complexity crept in when we had to figure out how to charge people for it.
Recently, we hit a major milestone: moving from an experimental Stripe setup into a production-ready billing system powered by Razorpay. While many developers default to Stripe because of its incredible documentation and global footprint, scaling ClipCrafter required us to rethink our payment architecture based on specific regional needs and usage enforcement.
The "Why" Behind the Pivot
When we first started prototyping Phase 10 (our Billing & Payments phase), a dual-provider approach seemed attractive for international coverage via Stripe + Razorpay. However, as development progressed, it became clear that maintaining two separate webhooks, two different subscription lifec/ycles, and twofold error handling was creating massive technical debt in our Inngest workflows.
We decided to simplify: Razorpay only.
By focusing on a single provider for the initial launch phase (specifically targeting India-based payments), we were able to prune significant amounts of boilerplate code from both our Next.js API routes and our background workers. We removed stripe_subscription_id entirely, cleaned up our database migrations in Supabase, and focused strictly on usage enforcement via a unified schema.
Implementing Usage Enforcement
The real challenge wasn't just "taking money"—it was ensuring that if someone is on the 'Starter' plan, they don’t accidentally trigger an unlimited number of high-compute video renders using our WebWorker architecture.
We needed a way to check usage limits before every single rendering job hit our worker queue via Inngest. Here is how we structured that logic in TypeScript:
// src/lib/billing.ts - Simplified Usage Check Logic
import { createClient } from '@supabase/supabase-js';
interface UserBillingRow {
planKey: 'starter' | 'pro';
videoCreditsRemaining: number;
}
export async function validateRenderPermissions(userId: string, supabaseClient: any) {
// 1. Fetch the current user billing status from Supabase
const { data: billingData, error } = await supabaseClient
.from('user_billing')
.select('planKey, videoCreditsRemaining')
.eq('id', userId)
.single();
if (error || !billingData) {
throw new Error("Could not retrieve billing information.");
}
const { planKey, videoCreditsRemaining } = billingData as UserBillingRow;
// 2. Enforce hard limits based on the Plan Key
console.log(`Checking permissions for ${planKey} user...`);
if (videoCreditsRemaining <= 0) {
throw new Error("No credits remaining! Please upgrade your plan.");
}
return true; // Permission granted to proceed with rendering task
}
The Lesson: Pruning is as important as Adding
The most satisfying part of this refactor wasn't adding the Razorpay integration—it was deleting everything related to Stripe. We removed entire client libraries, deleted webhook listeners that were no longer being triggered, and cleaned up our PlanBadge components which previously had "zombie" styles for plans we weren't even supporting anymore via a specific provider.
In software engineering (and especially in early-stage startups), there is an immense temptation to build every possible integration from Day 1 just because you might need it later. We learned that the complexity of managing two payment gateways far outweighed any potential benefit for our current user base.
Takeaway
If you're building a global SaaS, don't let "feature creep" infect your core infrastructure before they even pay their first invoice. Pick one provider, master its webhook lifecycle and usage enforcement patterns (like we did with Inngest + Supabase), and only expand when the market—not your imagination—demands it.
What’s your experience with payment gateways? Did you stick to Stripe or find a more localized solution for much of less friction? Let us know in the comments!
Top comments (0)