How to Build a Ruby on Rails Subscription Service: A Complete Guide

Learn how to build scalable subscription services in Ruby on Rails. Discover essential patterns, payment processing, usage tracking, and robust error handling. Get practical code examples and best practices. #RubyOnRails #SaaS

How to Build a Ruby on Rails Subscription Service: A Complete Guide

Building Subscription Services with Ruby on Rails

Subscription-based services have become the backbone of modern SaaS applications. I’ll share my experience implementing robust subscription systems using Ruby on Rails, covering essential patterns and practical solutions.

Setting Up the Foundation

The subscription model forms the core of our system. Let’s establish the basic structure:

class Subscription < ApplicationRecord
  belongs_to :user
  belongs_to :plan
  
  enum status: { trialing: 0, active: 1, paused: 2, canceled: 3 }
  
  validates :billing_period_start, presence: true
  validates :billing_period_end, presence: true
  
  scope :due_for_renewal, -> { active.where('billing_period_end <= ?', 7.days.from_now) }
end

Payment Processing Integration

I recommend using Stripe for payment processing. Here’s a service object pattern I’ve found effective:

class SubscriptionService
  def initialize(user, plan)
    @user = user
    @plan = plan
  end
  
  def create_subscription
    Subscription.transaction do
      stripe_subscription = Stripe::Subscription.create(
        customer: @user.stripe_customer_id,
        items: [{ price: @plan.stripe_price_id }]
      )
      
      @user.subscriptions.create!(
        plan: @plan,
        stripe_subscription_id: stripe_subscription.id,
        billing_period_start: Time.zone.at(stripe_subscription.current_period_start),
        billing_period_end: Time.zone.at(stripe_subscription.current_period_end)
      )
    end
  rescue Stripe::StripeError => e
    ErrorTracker.capture(e)
    false
  end
end

Usage Tracking

Monitoring service usage is crucial for metered billing:

class UsageTracker
  def track_event(user, event_type, quantity = 1)
    usage_record = user.usage_records.find_or_initialize_by(
      period_start: current_billing_period_start,
      period_end: current_billing_period_end,
      event_type: event_type
    )
    
    usage_record.increment!(:quantity, quantity)
  end
  
  def current_usage(user, event_type)
    user.usage_records
        .where(event_type: event_type)
        .where('period_start >= ?', user.current_subscription.billing_period_start)
        .sum(:quantity)
  end
end

Plan Switching Implementation

Managing plan changes requires careful handling of proration and billing adjustments:

class PlanSwitcher
  def switch_plan(subscription, new_plan)
    return if subscription.plan == new_plan
    
    stripe_subscription = Stripe::Subscription.retrieve(subscription.stripe_subscription_id)
    
    stripe_subscription.items = [{
      id: stripe_subscription.items.data[0].id,
      price: new_plan.stripe_price_id,
    }]
    
    stripe_subscription.save
    
    subscription.update!(
      plan: new_plan,
      billing_period_end: Time.zone.at(stripe_subscription.current_period_end)
    )
  rescue Stripe::StripeError => e
    ErrorTracker.capture(e)
    false
  end
end

Trial Period Management

Implementing trial periods with proper transition handling:

class TrialManager
  def start_trial(user, plan)
    return if user.had_trial_before?
    
    Subscription.transaction do
      subscription = user.subscriptions.create!(
        plan: plan,
        status: :trialing,
        trial_ends_at: 14.days.from_now,
        billing_period_start: Time.current,
        billing_period_end: 14.days.from_now
      )
      
      TrialNotificationJob.set(wait: 12.days).perform_later(subscription)
    end
  end
end

Payment Failure Handling

Implementing retry logic and customer communication:

class PaymentRetryManager
  RETRY_INTERVALS = [1.day, 3.days, 5.days]
  
  def handle_payment_failure(subscription)
    return if subscription.retry_count >= RETRY_INTERVALS.length
    
    subscription.increment!(:retry_count)
    retry_at = Time.current + RETRY_INTERVALS[subscription.retry_count - 1]
    
    PaymentRetryJob.set(wait_until: retry_at).perform_later(subscription)
    CustomerMailer.payment_failed(subscription.user).deliver_later
  end
end

Usage Analytics

Tracking key subscription metrics:

class SubscriptionAnalytics
  def monthly_recurring_revenue
    Subscription.active
               .joins(:plan)
               .sum('plans.price_cents')
  end
  
  def churn_rate(period = 30.days)
    canceled = Subscription.where(status: :canceled)
                         .where('updated_at >= ?', period.ago)
                         .count
    
    total = Subscription.where('created_at < ?', period.ago).count
    
    (canceled.to_f / total * 100).round(2)
  end
end

Webhook Processing

Handling Stripe webhook events:

class StripeWebhookProcessor
  def process_event(event)
    case event.type
    when 'customer.subscription.updated'
      handle_subscription_update(event.data.object)
    when 'invoice.payment_failed'
      handle_payment_failure(event.data.object)
    when 'customer.subscription.deleted'
      handle_subscription_cancellation(event.data.object)
    end
  end
  
  private
  
  def handle_subscription_update(stripe_subscription)
    subscription = Subscription.find_by!(stripe_subscription_id: stripe_subscription.id)
    subscription.update!(
      status: stripe_subscription.status,
      billing_period_end: Time.zone.at(stripe_subscription.current_period_end)
    )
  end
end

Testing Subscription Logic

Comprehensive testing ensures reliability:

RSpec.describe SubscriptionService do
  describe '#create_subscription' do
    let(:user) { create(:user) }
    let(:plan) { create(:plan) }
    
    it 'creates a subscription with correct billing dates' do
      VCR.use_cassette('stripe_subscription_creation') do
        subscription = described_class.new(user, plan).create_subscription
        
        expect(subscription).to be_persisted
        expect(subscription.billing_period_start).to be_present
        expect(subscription.billing_period_end).to be_present
      end
    end
    
    it 'handles Stripe errors gracefully' do
      allow(Stripe::Subscription).to receive(:create).and_raise(Stripe::CardError.new('', nil))
      
      result = described_class.new(user, plan).create_subscription
      expect(result).to be false
    end
  end
end

Performance Optimization

Implementing background jobs for heavy operations:

class SubscriptionRenewalJob < ApplicationJob
  queue_as :subscriptions
  
  def perform
    Subscription.due_for_renewal.find_each do |subscription|
      SubscriptionRenewalService.new(subscription).process
    rescue StandardError => e
      ErrorTracker.capture(e, subscription_id: subscription.id)
    end
  end
end

These techniques form a solid foundation for subscription-based services. The key is maintaining clear separation of concerns while ensuring robust error handling and comprehensive testing. Regular monitoring and analytics help identify areas for optimization and improvement.

Remember to implement proper logging and monitoring to track subscription lifecycle events and quickly identify issues. Consider implementing feature flags for gradual rollouts of new subscription features or pricing changes.

The subscription system should be flexible enough to accommodate future business requirements while maintaining consistency and reliability in billing operations.


// Keep Reading

Similar Articles

Unlocking Rust's Hidden Power: Emulating Higher-Kinded Types for Flexible Code
Ruby

Unlocking Rust's Hidden Power: Emulating Higher-Kinded Types for Flexible Code

Rust doesn't natively support higher-kinded types, but they can be emulated using traits and associated types. This allows for powerful abstractions like Functors and Monads. These techniques enable writing generic, reusable code that works with various container types. While complex, this approach can greatly improve code flexibility and maintainability in large systems.

Read Article →
Java Sealed Classes: Mastering Type Hierarchies for Robust, Expressive Code
Ruby

Java Sealed Classes: Mastering Type Hierarchies for Robust, Expressive Code

Sealed classes in Java define closed sets of subtypes, enhancing type safety and design clarity. They work well with pattern matching, ensuring exhaustive handling of subtypes. Sealed classes can model complex hierarchies, combine with records for concise code, and create intentional, self-documenting designs. They're a powerful tool for building robust, expressive APIs and domain models.

Read Article →