Why I Stopped Using Stripe Subscriptions and Built My Own Billing Engine
Muhammad Tayyab

Stripe's Subscriptions product is great — until your billing requirements outgrow it. Here's a practical breakdown of when to build your own recurring billing system, and how to architect it cleanly in Node.js and PostgreSQL.
Stripe is one of the best products in tech. Clean API, excellent docs, and a subscription system that gets you from zero to recurring revenue in a single afternoon. For most early-stage SaaS products, it's the obvious choice.
But here's the thing nobody tells you: Stripe Subscriptions is designed around Stripe's billing model — not yours. And the moment your product's billing logic starts diverging from that model, you're not using Stripe anymore. You're fighting it.
This is a breakdown of what that actually looks like, what a custom billing engine looks like under the hood, and how to know if building one makes sense for you.
Where Stripe Subscriptions Start to Break Down
Stripe Subscriptions handles the common cases beautifully: monthly plans, annual plans, metered billing, basic trials. If your product fits neatly into those boxes, stop reading — you're fine.
The cracks appear when your requirements get specific. Things like:
Custom billing intervals that don't map to calendar months. A 30-day rolling cycle isn't the same as a monthly subscription — especially for customers who sign up mid-month.
Per-customer trial logic. Not every customer gets the same trial length. Enterprise trials run differently than self-serve trials. Stripe's trial model is account-level, not business-logic-level.
Configurable retry behavior. Stripe's Smart Retries are good, but you don't control them. When a payment fails, your business might want to retry at day 1, day 4, and day 10 with a grace period — not whatever Stripe's ML decides.
Complex proration rules. Upgrades, downgrades, pauses, and resumes all generate proration behavior. Stripe's version is mathematically correct. It might not match what you promised your customers.
Once you've got two or three of these requirements stacking up, you spend more time working around Stripe than building your product. That's the signal.
What a Custom Billing Engine Actually Looks Like
The goal isn't to reinvent payment processing — that's still Stripe's job. What you're building is the orchestration layer: the logic that decides when to charge, how much, what happens when it fails, and what the customer's subscription state is at any given moment.
Stack: Node.js, Express, PostgreSQL. Nothing exotic.
The Core Schema
Three tables do most of the work. Here's the full schema with relationships:
-- subscriptions
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
plan_id UUID NOT NULL REFERENCES plans(id),
status TEXT NOT NULL, -- active | trialing | past_due | canceled
trial_ends_at TIMESTAMPTZ,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- plans
CREATE TABLE plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
price_cents INT NOT NULL,
interval_days INT NOT NULL, -- 30, 365, 14 — whatever you need
trial_days INT NOT NULL DEFAULT 0,
features JSONB
);
-- billing_attempts
CREATE TABLE billing_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subscription_id UUID NOT NULL REFERENCES subscriptions(id),
amount_cents INT NOT NULL,
status TEXT NOT NULL, -- pending | succeeded | failed
stripe_pi_id TEXT, -- Stripe PaymentIntent ID
retry_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMPTZ,
attempted_at TIMESTAMPTZ DEFAULT now()
);
-- Relationships:
-- subscriptions → plans (many-to-one)
-- billing_attempts → subscriptions (many-to-one)
-- subscriptions → customers (many-to-one)The key design decision: interval_days is an integer, not an enum like 'monthly' or 'annual'. This single change unlocks arbitrary billing intervals without any special-casing. A 30-day plan, a 90-day enterprise plan, a 14-day trial-converted plan — they're all the same logic.
billing_attempts is your audit log and retry engine in one table. Every charge attempt — successful or not — gets a row. You can query the full payment history for any subscription at any time, and your retry cron job just looks for rows where status = 'failed' AND next_retry_at <= now().
Flexible Billing Intervals
Stop thinking in 'monthly' and 'annual' — think in days. Your billing job calculates the next period end by adding interval_days to the current period_end. Clean, deterministic, no calendar edge cases.
The Retry Engine
When a payment fails, you write a billing_attempt record with status 'failed', set next_retry_at based on your retry schedule, and increment retry_count. A background cron queries for attempts due for retry and processes them.
Your retry schedule lives in config — not in Stripe's ML. [1, 3, 7] days is a reasonable default. After max retries, mark the subscription past_due and trigger your dunning flow. All fully in your control.
You Still Use Stripe — Just Differently
This is the part people miss. Replacing Stripe Subscriptions doesn't mean replacing Stripe. You still use Stripe Payment Intents for the actual charge, Stripe Customers for storing payment methods, and Stripe webhooks for charge outcomes.
The architecture is clean: your engine decides the what and when, Stripe handles the how. Full control of billing logic with none of the PCI compliance headache.
The Honest Tradeoffs
You own this system now. You need to monitor it, test it thoroughly, and maintain it. Billing bugs are the worst kind — they hit customers directly. That's the real cost of owning this layer.
Build your own if your billing requirements are genuinely complex and the friction of working around Stripe is already costing you significant engineering time. Stick with Stripe Subscriptions if your plans are simple or you're pre-product-market-fit.
Final Thoughts
Stripe Subscriptions is the right answer for a lot of products. But 'Stripe is great' and 'Stripe Subscriptions is always the right choice' are two different statements.
When your product's billing logic has matured to the point where you're constantly fighting the abstraction, building your own orchestration layer is a legitimate engineering decision — not a reinvention-of-the-wheel mistake.
Own the complexity that's core to your business. Delegate the rest. The payment processing itself? Delegate it. The billing logic that defines how your product works? That might be worth owning.