<MT />
Back to Blog
Node.jsPostgreSQLStripeBackendSaaSBilling

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

T

Muhammad Tayyab

April 6, 2026·7 min read
Custom billing engine architecture

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.

Back to all posts
Thanks for reading 🙏