Billing task. Voice anchor demands editorial prose — writing the deliverable normally. Here’s the MDX body:


Every time I add Stripe to a Rails MVP, I spend the first hour resisting the urge to model billing properly. There’s a tutorial-shaped pull toward plans, invoices, line_items, payment_methods — a whole double-entry ledger you will never reconcile against. There’s also the opposite trap: a single subscribed boolean that starts lying the moment a card expires. This post is the schema I actually ship — the smallest set of columns that lets a Rails app answer one question, “is this account paying, right now?”, without becoming Stripe’s understudy. It assumes Stripe holds the truth and your database is a cache of it. If you want the broader picture of what to leave out of an MVP, I wrote about scope separately. This is just the billing slice, and it’s smaller than you’d guess.

What “minimum viable” means for billing

The minimum viable billing schema is the set of records your app needs to make access decisions and to survive Stripe’s webhook stream without drifting out of sync. That’s the whole job. Not reporting, not analytics, not a finance team’s reconciliation — access decisions.

I’m scoping this to recurring subscriptions: monthly or annual plans, a fixed price per tier. Not metered usage, not one-time purchases, not marketplaces with Connect. Those need more, and I’ll say where the line is at the end.

The governing assumption is that Stripe is the system of record for money, and your Postgres rows are a read-through cache. Customers, subscriptions, invoices, proration, dunning, tax — all of that lives in Stripe and is hard to get right. You will not out-engineer it in an MVP, and you shouldn’t try. Your database stores pointers to Stripe’s objects plus just enough mirrored state to gate features without an API round-trip on every request.

Get that framing right and the schema almost writes itself. Get it wrong — try to own the money — and you’ll spend the rest of the project reconciling two ledgers that disagree.

The schema, and the one rule behind it

The rule: store IDs and mirrored status, never derive money locally. No amounts, no currency math, no computed total. If you find yourself adding a price_cents column, stop — that number now has two owners and they will diverge.

For a single-tenant-per-account app, the entire schema fits on the billable entity (call it Account or Organization) plus one events table:

ColumnTypeWhy it earns its place
stripe_customer_idstringLinks your account to Stripe’s customer. Created once, never changes.
stripe_subscription_idstringThe current subscription. Null when there’s none.
stripe_price_idstringWhich tier — the ID, not the name or the dollar amount.
subscription_statusstringA mirror of Stripe’s status enum (active, trialing, past_due, canceled…).
current_period_enddatetimeWhen access lapses if nothing renews. Your feature gate reads this.
cancel_at_period_endbooleanThe UI needs it (“cancels on the 14th”); access logic still trusts current_period_end.

The migration is unremarkable, which is the point:

class AddStripeBillingToAccounts < ActiveRecord::Migration[8.0]
  def change
    add_column :accounts, :stripe_customer_id, :string
    add_column :accounts, :stripe_subscription_id, :string
    add_column :accounts, :stripe_price_id, :string
    add_column :accounts, :subscription_status, :string
    add_column :accounts, :current_period_end, :datetime
    add_column :accounts, :cancel_at_period_end, :boolean,
              default: false, null: false

    add_index :accounts, :stripe_customer_id, unique: true
  end
end

The second table is the part most tutorials skip — an idempotency ledger so a redelivered webhook can’t double-apply:

create_table :stripe_events do |t|
  t.string :event_id, null: false
  t.datetime :processed_at
  t.timestamps
end
add_index :stripe_events, :event_id, unique: true

That’s six columns and a join-free events table. Everything else — invoice history, payment methods, receipts — you fetch from Stripe on demand or link out to Stripe’s own hosted billing portal, which is free and better than anything you’ll build in week one.

Pitfalls and anti-patterns

The failures here are predictable, and I’ve walked into most of them.

Trusting the checkout redirect. The success URL fires in the user’s browser; it is not a payment confirmation. Networks drop, tabs close, people bookmark the success page. Treat the redirect as “show a thank-you,” and let webhooks be the thing that actually grants access.

No idempotency. Stripe retries webhooks, sometimes for days, and will happily deliver the same event twice. Without the stripe_events guard, a duplicate invoice.paid can extend a period twice or re-trigger a welcome email. The unique index is the safety; lean on it.

A stale subscribed boolean. A flag you set once and never clear survives cancellations, failed renewals, and chargebacks. Status plus current_period_end is barely more code and tells the truth.

Storing amounts. The day Stripe’s price changes — a discount, a new tier, tax — your local number is wrong and silent about it.

Skipping signature verification. Without Stripe::Webhook.construct_event, your endpoint is an unauthenticated state-mutation API. Anyone who finds the URL can mark themselves paid.

Processing inline. Don’t reconcile inside the controller. Acknowledge fast, do the work in a job.

How the pattern looks end to end

Here’s the shape, conceptually — not a specific project, just how the pieces fit.

A logged-in account owner clicks Upgrade. You create a Stripe Checkout Session server-side, passing the stripe_customer_id (creating the customer first if it’s null) and the stripe_price_id for the tier. Stripe hosts the card form; you never touch card data. The user pays and gets bounced to your success URL, where you show a friendly “we’re setting things up” — and grant nothing yet.

Moments later, Stripe POSTs checkout.session.completed, then customer.subscription.created. Your webhook controller does the minimum and gets out of the way:

def create
  event = Stripe::Webhook.construct_event(
    request.body.read,
    request.env["HTTP_STRIPE_SIGNATURE"],
    endpoint_secret
  )

  return head :ok if StripeEvent.exists?(event_id: event.id)

  StripeEvent.create!(event_id: event.id)
  StripeSyncJob.perform_later(event.id)
  head :ok
end

The job re-fetches the subscription fresh from Stripe’s API — never trusting the webhook payload’s contents — and writes the mirrored columns: status, price ID, current_period_end. Re-fetching means a missed or out-of-order event self-heals on the next one.

Feature gates then read one method, no API call:

class Account < ApplicationRecord
  def billing_active?
    %w[active trialing].include?(subscription_status) &&
      current_period_end&.future?
  end
end

Renewals send invoice.paid and push current_period_end forward. A failed card sends past_due, and billing_active? flips to false on its own. Cancellation sets canceled. You wrote almost no billing logic — you mirrored Stripe’s and read it.

Your database doesn’t own the money. It owns a pointer to where the money lives, and a cached answer to one yes-or-no question.

— Self note

Definition of done

You’re finished — for an MVP — when these are true:

If you can demo a signup, a renewal, and a failed-card downgrade — all driven by webhooks, all idempotent — the billing slice is done. Anything beyond that is scope you chose to add, not scope the MVP demanded. Stripe’s test clocks let you fast-forward a month to verify renewals without waiting for one.

When the minimum is not enough

This schema breaks the moment billing stops being “one account, one subscription, one price.” Metered or usage-based billing needs you to record usage events and report them — a real table, real aggregation. Per-seat plans with proration need seat counts you can defend against Stripe’s math. Marketplaces using Connect introduce transfers and payouts that this model doesn’t touch. Enterprise deals with manual invoices and net-30 terms are a different product entirely.

The tell is simple: if a human in finance will ever ask your database a question Stripe can’t answer faster, you’ve outgrown the cache. Until then, six columns and an events table is not under-engineering — it’s the correct amount.

Closing

Here’s the falsifiable part. Take any Rails SaaS MVP and remove the subscription_status and current_period_end columns, keeping only a boolean. Within the first dozen real customers — a single expired card is enough — that boolean will grant access to someone who has stopped paying, and you’ll find out from a confused support email rather than from your code. The two columns cost one migration. The boolean costs trust. I haven’t yet seen the version of this where the boolean wins.