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.