When you run thousands of background jobs through Sidekiq, Redis becomes the bottleneck. Every job enqueue adds Redis writes, network round-trips, and memory pressure. This post covers a real-world optimization we applied and a broader toolkit for keeping Sidekiq lean.
The Problem: One Job Per Item
Imagine sending weekly emails to 10,000 users. The naive approach:
At 10,000 users, that’s 10,000 Redis operations and 10,000 scheduled entries. At 1M users, that’s 1M scheduled jobs in Redis. That’s expensive and slow.
The Fix: Batch + Staggered Scheduling
Instead of one job per user, we batch users and schedule each batch with a small delay:
Each worker still processes one user at a time internally, but we only enqueue one job per batch. Redis overhead drops by roughly 100x.
Why perform_in instead of chaining?
perform_in(delay, batch_ids) โ all jobs are scheduled immediately with their future timestamps. Sidekiq moves them into the ready queue at the right time regardless of other queue traffic.
Chaining (each job enqueues the next) โ the next batch only enters the queue after the current one finishes. If other jobs are busy, your email chain sits behind them and can be delayed significantly.
For time-sensitive jobs like “send at 8:46 AM local time,” upfront scheduling is the right choice.
Other Sidekiq Optimization Strategies
1. Bulk Enqueue (Sidekiq Pro/Enterprise)
Sidekiq::Client.push_bulk pushes many jobs in one Redis call:
# Single Redis call instead of N
Sidekiq::Client.push_bulk(
'class'=>WeeklyEmailWorker,
'args'=>user_ids.map { |id| [id] }
)
Useful when you don’t need per-job delays and want to minimize Redis round-trips.
2. Adjust Concurrency
Default is 10 threads per process. More threads = more concurrency but more memory:
# config/sidekiq.yml
:concurrency:25# Tune based on CPU/memory
Higher concurrency helps if jobs are I/O-bound (HTTP, DB, email). For CPU-bound jobs, lower concurrency is usually better.
3. Use Dedicated Queues
Separate heavy jobs from light ones:
# config/sidekiq.yml
:queues:
-[critical, 3]# 3x weight
-[default, 2]
-[low, 1]
Critical jobs get more CPU time. Low-priority jobs don’t block the rest.
A developer’s guide to understanding Stripe’s API transformation and avoiding common migration pitfalls
The payment processing landscape has evolved dramatically over the past decade, and Stripe has been at the forefront of this transformation. One of the most significant changes in Stripe’s ecosystem was the transition from the Charges API to the Payment Intents API. This shift wasn’t just a cosmetic update – it represented a fundamental reimagining of how online payments should work in an increasingly complex global marketplace.
The Old World: Charges API (2011-2019)
The Simple Days
When Stripe first launched, online payments were relatively straightforward. The Charges API reflected this simplicity:
# The old way - direct charge creation
charge = Stripe::Charge.create({
amount: 2000,
currency: 'usd',
source: 'tok_visa', # Token from Stripe.js
description: 'Example charge'
})
if charge.paid
# Payment succeeded, fulfill order
fulfill_order(charge.id)
else
# Payment failed, show error
handle_error(charge.failure_message)
end
This approach was beautifully simple: create a charge, check if it succeeded, done. The API returned a charge object with an ID like ch_1234567890, and that was your payment.
What Made It Work
The Charges API thrived in an era when:
Card payments dominated – Most transactions were simple credit/debit cards
3D Secure was optional – Strong customer authentication wasn’t mandated
Regulations were simpler – PCI DSS was the main compliance concern
Payment methods were limited – Mostly cards, with PayPal as the main alternative
Mobile payments were nascent – Most transactions happened on desktop browsers
The Cracks Begin to Show
As the payments ecosystem evolved, the limitations of the Charges API became apparent:
Authentication Challenges: When 3D Secure authentication was required, the simple charge-and-done model broke down. Developers had to handle redirects, callbacks, and asynchronous completion manually.
Mobile Payment Integration: Apple Pay and Google Pay required more complex flows that didn’t map well to direct charge creation.
Regulatory Compliance: European PSD2 regulations introduced Strong Customer Authentication (SCA) requirements that the Charges API couldn’t elegantly handle.
Webhook Reliability: With complex payment flows, relying on synchronous responses became insufficient. Webhooks were critical, but the Charges API didn’t provide a cohesive event model.
The Catalyst: PSD2 and Strong Customer Authentication
The European Union’s Revised Payment Services Directive (PSD2), which came into effect in 2019, was the final nail in the coffin for simple payment flows. PSD2 mandated Strong Customer Authentication (SCA) for most online transactions, requiring:
Two-factor authentication for customers
Dynamic linking between payment and authentication
Exemption handling for low-risk transactions
The Charges API, with its synchronous create-and-complete model, simply couldn’t handle these requirements elegantly.
The New Era: Payment Intents API (2019-Present)
A Paradigm Shift
Stripe’s response was revolutionary: instead of treating payments as simple charge operations, they reconceptualized them as intents that could evolve through multiple states:
# The modern way - intent-based payments
payment_intent = Stripe::PaymentIntent.create({
amount: 2000,
currency: 'usd',
payment_method: 'pm_card_visa',
confirmation_method: 'manual',
capture_method: 'automatic'
})
case payment_intent.status
when 'requires_confirmation'
# Confirm the payment intent
payment_intent.confirm
when 'requires_action'
# Handle 3D Secure or other authentication
handle_authentication(payment_intent.client_secret)
when 'succeeded'
# Payment completed, fulfill order
fulfill_order(payment_intent.id)
when 'requires_payment_method'
# Payment failed, request new payment method
handle_payment_failure
end
The Intent Lifecycle
Payment Intents introduced a state machine that could handle complex payment flows:
Payment Intents provide richer webhook events that track the entire payment lifecycle:
# webhook_endpoints.rb
case event.type
when 'payment_intent.succeeded'
handle_successful_payment(event.data.object)
when 'payment_intent.payment_failed'
handle_failed_payment(event.data.object)
when 'payment_intent.requires_action'
notify_customer_action_required(event.data.object)
end
3. Client-Side Integration
The Payment Intents API encouraged better client-side integration through Stripe Elements and mobile SDKs:
Many applications need to update their database schemas to accommodate both old and new payment types:
# Migration to support both charge and payment intent IDs
class AddPaymentIntentSupport < ActiveRecord::Migration[6.0]
def change
add_column :payments, :stripe_payment_intent_id, :string
add_column :payments, :payment_type, :string, default: 'charge'
add_index :payments, :stripe_payment_intent_id
add_index :payments, :payment_type
end
end
# Updated model to handle both
class Payment < ApplicationRecord
def stripe_id
case payment_type
when 'payment_intent'
stripe_payment_intent_id
when 'charge'
stripe_charge_id
end
end
def refundable_charge_id
if payment_type == 'payment_intent'
# Fetch the actual charge ID from the payment intent
pi = Stripe::PaymentIntent.retrieve(stripe_payment_intent_id)
pi.charges.data.first.id
else
stripe_charge_id
end
end
end
Webhook Handler Updates
Webhook handling becomes more sophisticated with Payment Intents:
# Legacy charge webhook handling
def handle_charge_webhook(event)
charge = event.data.object
case event.type
when 'charge.succeeded'
mark_payment_successful(charge.id)
when 'charge.failed'
mark_payment_failed(charge.id)
end
end
# Modern payment intent webhook handling
def handle_payment_intent_webhook(event)
payment_intent = event.data.object
case event.type
when 'payment_intent.succeeded'
# Payment completed successfully
complete_order(payment_intent.id)
when 'payment_intent.payment_failed'
# All payment attempts have failed
cancel_order(payment_intent.id)
when 'payment_intent.requires_action'
# Customer needs to complete authentication
notify_action_required(payment_intent.id, payment_intent.client_secret)
when 'payment_intent.amount_capturable_updated'
# Partial capture scenarios
handle_partial_authorization(payment_intent.id)
end
end
Best Practices for Modern Stripe Integration
1. Embrace Asynchronous Patterns
With Payment Intents, assume payments are asynchronous:
class PaymentProcessor
def create_payment(amount, customer_id, payment_method_id)
payment_intent = Stripe::PaymentIntent.create({
amount: amount,
currency: 'usd',
customer: customer_id,
payment_method: payment_method_id,
confirmation_method: 'automatic',
return_url: success_url
})
# Don't assume immediate success
case payment_intent.status
when 'succeeded'
complete_payment_immediately(payment_intent)
when 'requires_action'
# Send client_secret to frontend for authentication
{ status: 'requires_action', client_secret: payment_intent.client_secret }
when 'requires_payment_method'
{ status: 'failed', error: 'Payment method declined' }
else
# Wait for webhook confirmation
{ status: 'processing', payment_intent_id: payment_intent.id }
end
end
end
2. Implement Robust Webhook Handling
Webhooks are critical for Payment Intentsโimplement them defensively:
class StripeWebhookController < ApplicationController
protect_from_forgery except: :handle
def handle
payload = request.body.read
sig_header = request.env['HTTP_STRIPE_SIGNATURE']
begin
event = Stripe::Webhook.construct_event(
payload, sig_header, ENV['STRIPE_WEBHOOK_SECRET']
)
rescue JSON::ParserError, Stripe::SignatureVerificationError
head :bad_request and return
end
# Handle idempotently
return head :ok if processed_event?(event.id)
case event.type
when 'payment_intent.succeeded'
PaymentSuccessJob.perform_later(event.data.object.id)
when 'payment_intent.payment_failed'
PaymentFailureJob.perform_later(event.data.object.id)
end
mark_event_processed(event.id)
head :ok
end
private
def processed_event?(event_id)
Rails.cache.exist?("stripe_event_#{event_id}")
end
def mark_event_processed(event_id)
Rails.cache.write("stripe_event_#{event_id}", true, expires_in: 24.hours)
end
end
3. Handle Multiple Payment Methods Gracefully
Payment Intents excel at handling diverse payment methods:
Payment Intents provide detailed error information:
def handle_payment_error(payment_intent)
last_payment_error = payment_intent.last_payment_error
case last_payment_error&.code
when 'authentication_required'
# Redirect to 3D Secure
redirect_to_authentication(payment_intent.client_secret)
when 'card_declined'
decline_code = last_payment_error.decline_code
case decline_code
when 'insufficient_funds'
show_error("Insufficient funds on your card")
when 'expired_card'
show_error("Your card has expired")
else
show_error("Your card was declined")
end
when 'processing_error'
show_error("A processing error occurred. Please try again.")
else
show_error("An unexpected error occurred")
end
end
The Future: What’s Next?
1. Embedded Payments
Stripe continues to innovate with embedded payment solutions that make Payment Intents even more powerful:
As real-time payment networks like FedNow and Open Banking expand, Payment Intents provide the flexibility to support these new methods seamlessly.
3. Cross-Border Optimization
Payment Intents are evolving to better handle multi-currency and cross-border transactions with improved routing and local payment method support.
Key Takeaways for Developers
Payment Intents are the future: If you’re building new payment functionality, start with Payment Intents, not Charges.
Embrace asynchronous patterns: Don’t expect payments to complete immediately. Design your system around webhooks and state management.
Handle the ID confusion: Remember that Payment Intents (pi_) contain Charges (ch_). Refunds and some other operations still work on charge IDs.
Implement robust webhook handling: With complex payment flows, webhooks become critical infrastructure, not nice-to-have features.
Test thoroughly: The increased complexity of Payment Intents requires more comprehensive testing, especially around authentication flows and edge cases.
Monitor proactively: Use Stripe’s dashboard and logs extensively during development and deployment to understand payment flow behavior.
Conclusion
The evolution from Stripe’s Charges API to Payment Intents represents more than just a technical upgradeโit’s a fundamental shift toward a more flexible, regulation-compliant, and globally-aware payment processing model. While the migration requires thoughtful planning and careful implementation, the benefits in terms of supported payment methods, authentication handling, and regulatory compliance make it essential for any serious payment processing application.
The key is to approach the migration systematically: understand the differences, plan for the ID confusion, implement robust webhook handling, and test extensively. With these foundations in place, Payment Intents unlock capabilities that simply weren’t possible with the older Charges API.
As global payment regulations continue to evolve and new payment methods emerge, Payment Intents provide the architectural flexibility to adapt and grow. The initial complexity investment pays dividends in long-term maintainability and feature capability.
For developers still using the Charges API, the writing is on the wall: it’s time to embrace the future of payment processing with Payment Intents.
Have you encountered similar challenges migrating from Charges to Payment Intents? What patterns have worked best in your applications? Share your experiences in the comments below.
Ruby is one of the few languages where classes are objects, capable of holding both instance behavior and class-level behavior. This flexibility comes from a powerful internal structure: the singleton class, also known as the eigenclass. Every Ruby object has one โ including classes themselves.
When developers write class << self, they are opening a special, hidden class that Ruby uses to store methods that belong to the class object, not its instances. This technique is the backbone of Ruby’s expressive meta-programming features and is used heavily in Rails, Sidekiq, ActiveRecord, RSpec, and nearly every major Ruby framework.
This article explains why Ruby has singleton classes, what they enable, and when you should use class << self instead of def self.method for defining class-level behavior.
In Ruby, writing:
class Payment; end
creates an object:
Payment.instance_of?(Class) # => true
Since Payment is an object, it can have:
Its own methods
Its own attributes
Its own included modules
Just like any other object.
Ruby stores these class-specific methods in a special internal structure: the singleton class of Payment.
When you define a class method:
def self.process
end
Ruby is actually doing this under the hood:
Open the singleton class of Payment
Define process inside it
So:
class << Payment
def process; end
end
and:
def Payment.process; end
and:
def self.process; end
All do the same thing.
But class << self unlocks far more power.
Each Ruby object has:
[ Object ] ---> [ Singleton Class ] ---> [ Its Class ]
Instance methods live in Payment. Class methods live in Payment's eigenclass.
The eigenclass is where Ruby stores:
Class methods
Per-object overrides
Class-specific attributes
DSL behaviors
class << self
def load; end
def export; end
def sync; end
end
Cleaner than:
def self.load; end
def self.export; end
def self.sync; end
This is a huge advantage.
class << self
private
def connection_pool
@pool ||= ConnectionPool.new
end
end
Using def self.method cannot make the method private โ Ruby doesn’t allow it.
class << self
include CacheHelpers
end
This modifies class-level behavior, not instance behavior.
Rails uses this technique everywhere.
You must open the eigenclass:
class << self
def new(*args)
puts "Creating a new Payment!"
super
end
end
This cannot be done properly with def self.new.
class << self
attr_accessor :config
end
Usage:
Payment.config = { currency: "USD" }
This config belongs to the class itself.
Example from ActiveRecord:
class << self
def has_many(name)
# defines association
end
end
Or RSpec:
class << self
def describe(text, &block)
# builds DSL structure
end
end
When you write:
class Order < ApplicationRecord
has_many :line_items
end
Internally Rails does:
class Order
class << self
def has_many(name)
# logic here
end
end
end
This is how Rails builds its elegant DSL.
class << self
def before_save(method_name)
set_callback(:save, :before, method_name)
end
end
Again, these DSL methods live in the singleton class.
โ Use def self.method_name when:
Only defining 1โ2 methods
Simpler readability is preferred
โ Use class << self when:
You have many class methods
You require private class methods
You need to include modules at class level
You are building DSLs or metaprogramming-heavy components
You need to override class-level behavior (new, allocate)
Opening a class’s singleton class (class << self) is not just a stylistic choice โ it is a powerful meta-programming technique that lets you modify the behavior of the class object itself. Because Ruby treats classes as first-class objects, their singleton classes hold the key to defining class methods, private class-level utilities, DSLs, and dynamic meta-behavior.
Understanding how and why Ruby uses the eigenclass gives you deeper insight into the design of Rails, Sidekiq, ActiveRecord, and virtually all major Ruby libraries.
Itโs one of the most elegant aspects of Ruby’s object model โ and one of its most powerful once mastered.
In Ruby, everything is an object – and that includes classes themselves. A class like Payment is actually an instance of Class, meaning it can have its own methods, attributes, and behavior just like any other object. Because every object in Ruby has a special hidden class called a singleton class (or eigenclass), Ruby uses this mechanism to store methods that belong specifically to the class object, rather than to its instances.
When developers open a class’s eigenclass using class << self, they are directly modifying this singleton class, gaining access to unique meta-programming abilities not available through normal def self.method definitions. This approach lets you define private class methods, include modules into a class’s singleton behavior, override internal methods like new or allocate, group multiple class methods cleanly, and create flexible DSLs. Ultimately, opening the eigenclass enables fine-grained control over a Ruby class’s meta-level behavior, a powerful tool when writing expressive, maintainable frameworks and advanced Ruby code.
? Why Ruby Needs a Singleton Class for the Class Object
Ruby separates instance behavior from class behavior:
Instance methods live in the class (Payment)
Class methods live in the classโs singleton class (Payment.singleton_class)
This means:
def self.process
end
and:
class << self
def process
end
end
are doing the same thing – defining a method on the class’s eigenclass.
But class << self gives you more control.
What You Can Do With class << self That You Can’t Do With def self.method
1. Group multiple class methods without repeating self.
class << self
def load_data; end
def generate_stats; end
def export; end
end
Cleaner and more readable when many class methods exist.
2. Make class methods private
This is a BIG reason to open the eigenclass.
class << self
private
def secret_config
"hidden!"
end
end
With def self.secret_config, you cannot make it private.
3. Add modules to the class’s singleton behavior
This modifies the class itself, not its instances.
class << self
include SomeClassMethods
end
Equivalent to:
extend SomeClassMethods
But allows mixing visibility (public/private/protected).
class << self
def allocate
puts "custom allocation"
super
end
end
This cannot be done correctly with def self.allocate.
5. Implement DSLs and class-level configuration
Rails, RSpec, Sidekiq, and ActiveRecord all use this.
class << self
attr_accessor :config
end
Now the class has its own state:
Payment.config = { mode: :test }
Understanding the Bigger Picture โ Ruby’s Meta-Object Model
Ruby treats classes as objects, and every object has:
A class where instance methods live
A singleton class where methods specific to that object live
So:
Instance methods โ stored in the class (Payment)
Class methods โ stored in the singleton class (Payment.singleton_class)
Opening the eigenclass means directly modifying that second structure.
When Should You Use class << self?
Use class << self when:
โ You have several class methods to define โ You need private/protected class methods โ You want to include or extend modules into the class’s behavior โ You need to override class-level built-ins (new, allocate) โ You’re implementing DSLs or framework-level code
Use def self.method when:
โ You’re defining one or two simple class methods โ You want the simplest, most readable syntax
๐ฏ Final Takeaway
Opening the singleton class at the class level isn’t just stylistic โ it unlocks capabilities that normal class method definitions cannot provide. It’s a powerful tool for clean organization, encapsulation, and meta-programming. Frameworks like Rails rely heavily on this pattern because it allows precise control over how classes behave at a meta-level.
Understanding this distinction helps you write cleaner, more flexible Ruby code โ and it deepens your appreciation of Ruby’s elegant object model.
In the next article, we can check more examples in detail.
How we transformed fragmented payment tracking into a comprehensive admin interface that gives business teams complete visibility into every payment attempt.
Payment systems are mission-critical components that directly impact revenue and customer trust, making comprehensive testing absolutely essential. A robust testing strategy must cover three distinct layers: isolated unit tests that verify individual payment service behaviours, integration tests that ensure proper webhook handling and external API interactions, and feature tests that validate the complete user experience from payment initiation to admin dashboard visibility. This multi-layered approach ensures that payment failures are caught early in development, edge cases are properly handled, and business stakeholders can rely on accurate payment data for decision-making.
Unit Testing Payment Service
Unit tests form the foundation of payment system reliability by isolating and verifying the core payment processing logic without external dependencies, ensuring that different payment scenarios (success, card declined, network errors) are handled correctly and consistently.
# spec/services/payment_service_spec.rb
RSpec.describe PaymentService do
let(:customer) { create(:customer, :with_payment_method) }
describe '.charge' do
context 'successful payment' do
before do
allow(Stripe::PaymentIntent).to receive(:create)
.and_return(double(status: 'succeeded', id: 'pi_success_123', to_hash: {}))
end
it 'creates successful transaction' do
transaction = PaymentService.charge(2999, 'Test charge', customer)
expect(transaction).to be_persisted
expect(transaction.success?).to be true
expect(transaction.amount_cents).to eq(2999)
end
it 'creates payment record association' do
expect {
transaction = PaymentService.charge(2999, 'Test charge', customer)
customer.payment_records.create!(transaction: transaction)
}.to change { customer.payment_records.count }.by(1)
end
end
context 'card declined' do
let(:declined_error) do
Stripe::CardError.new('Card declined', 'card_declined',
json_body: { 'error' => { 'code' => 'card_declined',
'message' => 'Your card was declined.' } })
end
before do
allow(Stripe::PaymentIntent).to receive(:create).and_raise(declined_error)
end
it 'creates failed transaction with error details' do
transaction = PaymentService.charge(2999, 'Test charge', customer)
expect(transaction).to be_persisted
expect(transaction.success?).to be false
expect(transaction.error_code).to eq('card_declined')
expect(transaction.error_message).to eq('Your card was declined.')
end
end
context 'network error' do
before do
allow(Stripe::PaymentIntent).to receive(:create)
.and_raise(Stripe::APIConnectionError.new('Network error'))
end
it 'creates failed transaction with network error' do
transaction = PaymentService.charge(2999, 'Test charge', customer)
expect(transaction).to be_persisted
expect(transaction.success?).to be false
expect(transaction.error_message).to eq('Network error')
end
end
context 'zero amount' do
it 'creates successful zero-amount transaction' do
transaction = PaymentService.charge(0, 'Free item', customer)
expect(transaction).to be_persisted
expect(transaction.success?).to be true
expect(transaction.amount_cents).to eq(0)
end
end
end
end
Integration Testing with Webhooks
Integration tests validate the critical communication pathways between your application and Stripe’s web-hook system, ensuring that payment status updates are properly received, parsed, and stored even when network conditions or timing issues occur.
# spec/controllers/webhooks/stripe_controller_spec.rb
RSpec.describe Webhooks::StripeController do
let(:customer) { create(:customer) }
describe 'payment_intent.payment_failed webhook' do
let(:webhook_payload) do
{
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'pi_failed_123',
amount: 2999,
currency: 'usd',
customer: customer.stripe_customer_id,
last_payment_error: {
code: 'card_declined',
message: 'Your card was declined.'
}
}
}
}
end
it 'creates failed transaction record' do
expect {
post :handle_webhook, params: webhook_payload
}.to change { Transaction.count }.by(1)
transaction = Transaction.last
expect(transaction.success?).to be false
expect(transaction.error_code).to eq('card_declined')
end
it 'associates transaction with customer' do
expect {
post :handle_webhook, params: webhook_payload
}.to change { customer.payment_records.count }.by(1)
end
end
end
Feature Testing Admin Interface
Feature tests provide end-to-end validation of the admin dashboard experience, verifying that business users can access complete payment information, understand transaction statuses at a glance, and take appropriate actions based on payment data.
# spec/features/admin/customer_payments_spec.rb
RSpec.describe 'Customer Payment Admin', type: :feature do
let(:admin_user) { create(:admin_user) }
let(:customer) { create(:customer) }
before { login_as(admin_user) }
scenario 'viewing customer payment history' do
# Create test transactions
successful_transaction = create(:transaction, :successful, amount_cents: 2999)
failed_transaction = create(:transaction, :failed, amount_cents: 4999)
customer.payment_records.create!(transaction: successful_transaction)
customer.payment_records.create!(transaction: failed_transaction)
visit admin_customer_path(customer)
within('#payment-history') do
expect(page).to have_content('$29.99')
expect(page).to have_content('SUCCESS')
expect(page).to have_content('$49.99')
expect(page).to have_content('FAILED')
expect(page).to have_link('View Stripe Dashboard')
expect(page).to have_link('Retry Payment')
end
end
end
Advanced Implementation Patterns
Beyond basic payment processing, production payment systems require sophisticated patterns to handle complex business scenarios like multi-payment methods per customer, subscription lifecycle events, and intelligent error recovery. These advanced patterns separate robust enterprise systems from simple payment integrations by providing the flexibility and resilience needed for real-world business operations. Implementing these patterns proactively prevents technical debt and ensures your payment system can evolve with changing business requirements.
1. Payment Method Management System
A comprehensive payment method management system allows customers to store multiple payment methods securely while giving businesses the flexibility to handle payment method updates, expirations, and customer preferences without disrupting service continuity.
# app/services/payment_method_manager.rb
class PaymentMethodManager
def initialize(customer)
@customer = customer
end
def add_payment_method(payment_method_id)
begin
# Attach to customer
Stripe::PaymentMethod.attach(payment_method_id, {
customer: @customer.stripe_customer_id
})
# Store locally
@customer.customer_payment_methods.create!(
stripe_payment_method_id: payment_method_id,
is_default: @customer.customer_payment_methods.empty?
)
{ success: true }
rescue Stripe::InvalidRequestError => e
{ success: false, error: e.message }
end
end
def set_default_payment_method(payment_method_id)
# Update Stripe customer
Stripe::Customer.update(@customer.stripe_customer_id, {
invoice_settings: { default_payment_method: payment_method_id }
})
# Update local records
@customer.customer_payment_methods.update_all(is_default: false)
@customer.customer_payment_methods
.find_by(stripe_payment_method_id: payment_method_id)
&.update!(is_default: true)
end
def remove_payment_method(payment_method_id)
# Detach from Stripe
Stripe::PaymentMethod.detach(payment_method_id)
# Remove local record
@customer.customer_payment_methods
.find_by(stripe_payment_method_id: payment_method_id)
&.destroy!
end
end
2. Subscription Lifecycle Management
Subscription lifecycle management encompasses the complete journey from trial creation through renewal, pause, and cancellation, ensuring that billing events are properly tracked and business logic is consistently applied across all subscription state changes.
# app/services/subscription_manager.rb
class SubscriptionManager
def initialize(customer)
@customer = customer
end
def create_subscription(price_id, trial_days = nil)
subscription_params = {
customer: @customer.stripe_customer_id,
items: [{ price: price_id }],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
}
subscription_params[:trial_period_days] = trial_days if trial_days
stripe_subscription = Stripe::Subscription.create(subscription_params)
# Create local subscription record
subscription = @customer.subscriptions.create!(
stripe_subscription_id: stripe_subscription.id,
status: stripe_subscription.status,
current_period_start: Time.at(stripe_subscription.current_period_start),
current_period_end: Time.at(stripe_subscription.current_period_end),
trial_end: stripe_subscription.trial_end ? Time.at(stripe_subscription.trial_end) : nil
)
# Track the creation attempt
if stripe_subscription.latest_invoice.payment_intent
track_subscription_payment(stripe_subscription, subscription)
end
subscription
end
private
def track_subscription_payment(stripe_subscription, local_subscription)
payment_intent = stripe_subscription.latest_invoice.payment_intent
transaction = Transaction.create!(
amount_cents: payment_intent.amount,
success: payment_intent.status == 'succeeded',
stripe_data: payment_intent.to_hash,
stripe_payment_id: payment_intent.id,
transaction_type: 'subscription_payment'
)
local_subscription.payment_records.create!(transaction: transaction)
end
end
3. Comprehensive Error Handling and Notifications
Advanced error handling goes beyond simple retry logic to include intelligent categorization of payment failures, automated customer communication workflows, and escalation procedures that help recover revenue while maintaining positive customer relationships.
# app/jobs/payment_failure_handler_job.rb
class PaymentFailureHandlerJob < ApplicationJob
def perform(transaction_id)
transaction = Transaction.find(transaction_id)
return if transaction.success?
# Find associated customer
customer = find_customer_for_transaction(transaction)
return unless customer
case transaction.error_code
when 'card_declined', 'insufficient_funds'
handle_declined_card(customer, transaction)
when 'expired_card'
handle_expired_card(customer, transaction)
when 'authentication_required'
handle_3ds_required(customer, transaction)
else
handle_generic_failure(customer, transaction)
end
end
private
def handle_declined_card(customer, transaction)
# Send customer notification
PaymentFailureMailer.card_declined(customer, transaction).deliver_now
# Update customer status
customer.update!(payment_status: 'payment_failed', last_payment_failure_at: Time.current)
# Schedule retry in 3 days
PaymentRetryJob.set(wait: 3.days).perform_later(customer.id, transaction.id)
end
def handle_expired_card(customer, transaction)
PaymentFailureMailer.card_expired(customer, transaction).deliver_now
customer.update!(payment_status: 'card_expired')
end
def find_customer_for_transaction(transaction)
payment_record = PaymentRecord.find_by(transaction: transaction)
return nil unless payment_record&.payable_type == 'Customer'
payment_record.payable
end
end
Business Intelligence and Reporting
Raw payment data becomes truly valuable when transformed into actionable business insights that drive strategic decisions and operational improvements. Business intelligence for payment systems encompasses both real-time monitoring capabilities that help identify and resolve issues quickly, and analytical reporting that reveals patterns in customer behaviour, payment success rates, and revenue optimization opportunities. These capabilities transform payment systems from cost centers into strategic business assets that actively contribute to growth and customer satisfaction.
1. Payment Analytics Dashboard
A comprehensive analytics dashboard transforms scattered payment data into clear, actionable insights that help business teams identify trends, optimize conversion rates, and proactively address payment issues before they impact revenue or customer experience.
Automated payment recovery systems intelligently retry failed payments based on error type and customer history, implementing business rules that maximize revenue recovery while respecting customer preferences and avoiding negative experiences that could damage relationships.
# app/services/payment_recovery_service.rb
class PaymentRecoveryService
def self.process_failed_payments
# Find customers with recent payment failures
failed_payment_records = PaymentRecord.joins(:transaction)
.where(transactions: { success: false })
.where(created_at: 1.day.ago..Time.current)
.includes(:payable, :transaction)
failed_payment_records.each do |payment_record|
next unless payment_record.payable_type == 'Customer'
customer = payment_record.payable
retry_payment_for_customer(customer, payment_record.transaction)
end
end
private
def self.retry_payment_for_customer(customer, original_transaction)
# Only retry certain error types
return unless retryable_error?(original_transaction.error_code)
# Don't retry if customer has been marked as do-not-retry
return if customer.payment_retry_disabled?
# Attempt payment with same amount
new_transaction = PaymentService.charge(
original_transaction.amount_cents,
"Retry: #{original_transaction.stripe_data['description']}",
customer
)
customer.payment_records.create!(
transaction: new_transaction,
retry_of_transaction_id: original_transaction.id
)
if new_transaction.success?
PaymentRecoveryMailer.payment_recovered(customer, new_transaction).deliver_later
else
# Mark for manual review after multiple failures
customer.update!(requires_manual_payment_review: true)
end
end
def self.retryable_error?(error_code)
%w[api_connection_error rate_limit_error temporary_failure].include?(error_code)
end
end
Conclusion
The key principles to remember:
Track Everything: Every payment attempt, successful or failed, tells part of your business story
Design for Non-Technical Users: Transform complex payment data into actionable business intelligence
Plan for Scale: Use caching, efficient queries, and smart data structures
Test Thoroughly: Payment systems require comprehensive testing of both happy and sad paths
Monitor Continuously: Build dashboards and alerts that help you catch issues before they impact customers
Ready to implement robust payment tracking in your Rails application? Start with the foundational data models, then build up your service layer and admin interfaces systematically. Remember: comprehensive payment visibility is not just a technical requirementโit’s a business advantage.
How we transformed fragmented payment tracking into a comprehensive admin interface that gives business teams complete visibility into every payment attempt.
Introduction: The Payment Visibility Challenge
When building SaaS applications with complex payment flows, one of the most critical yet overlooked aspects is payment visibility for non-technical teams. While Stripe provides excellent APIs and webhooks, the challenge lies in making this data accessible and actionable for marketing teams, customer success, and business operations.
In this post, we’ll walk through a comprehensive implementation for building robust Stripe payment tracking in a Ruby on Rails application, transforming scattered payment data into a unified admin dashboard that provides complete visibility into every payment attemptโsuccessful or failed.
The Problem: Incomplete Payment Tracking
Common Issues in Production Systems
Many Rails applications suffer from similar payment tracking gaps:
Selective Tracking: Only successful payments are recorded
Fragmented Data: Payment attempts scattered across different models
Poor Error Visibility: Failed payments disappear into the void
Limited Business Intelligence: No way to analyze payment patterns
Typical Implementation Problems
# Anti-pattern 1: Only tracking successes
def process_subscription_payment(user, amount)
payment = stripe_service.charge(user.stripe_customer_id, amount)
if payment.succeeded?
user.payment_records.create!(
amount: amount,
status: 'success',
stripe_payment_id: payment.id
)
# โ Failed payments are lost forever
end
payment
end
# Anti-pattern 2: Missing associations
def charge_customer_wallet(customer, amount, description)
charge = create_stripe_charge(customer, amount, description)
# โ Charge created but not linked to customer
Transaction.create!(
amount: amount,
success: charge.succeeded?,
stripe_data: charge.to_hash
)
charge
end
Architecture Deep Dive: Rails + Stripe Integration Patterns
Successful Stripe integration in Rails applications requires more than just API callsโit demands a well-architected system that handles the complexity of payment processing while maintaining clean, maintainable code. The foundation of this architecture lies in polymorphic associations that allow payments to be linked to various business entities (customers, orders, subscriptions), combined with service objects that abstract Stripe’s API complexity and provide consistent error handling. This architectural approach ensures that payment logic remains decoupled from business models while providing the flexibility to support diverse payment scenarios across your application.
The Foundation: Polymorphic Payment Records
Our solution uses a polymorphic association pattern that allows payments to be tracked across different business entities:
# app/models/payment_record.rb
class PaymentRecord < ApplicationRecord
belongs_to :transaction
belongs_to :payable, polymorphic: true # Customer, Order, Subscription, etc.
end
# app/models/customer.rb
class Customer < ApplicationRecord
has_many :payment_records, as: :payable
has_many :transactions, through: :payment_records
def charge(amount_cents, description = 'Payment')
payment_service.charge(amount_cents, description, self)
end
end
# app/models/transaction.rb
class Transaction < ApplicationRecord
# amount_cents: integer
# success: boolean
# stripe_data: jsonb (full Stripe response)
# stripe_payment_id: string
# error_code: string
# error_message: text
scope :successful, -> { where(success: true) }
scope :failed, -> { where(success: false) }
def declined?
!success && stripe_data.dig('error', 'code') == 'card_declined'
end
def error_type
stripe_data.dig('error', 'type')
end
end
The Payment Service: Stripe API Abstraction
A centralized payment service acts as the bridge between your Rails application and Stripe’s API, encapsulating all the complexity of error handling, response processing, and data transformation while providing a clean, consistent interface for the rest of your application to interact with.
# app/services/payment_service.rb
class PaymentService
class << self
def charge(amount_cents, description, customer)
return create_zero_transaction if amount_cents <= 0
success = false
stripe_response = nil
begin
stripe_response = Stripe::PaymentIntent.create({
amount: amount_cents,
currency: 'usd',
description: description,
customer: customer.stripe_customer_id,
payment_method: customer.default_payment_method_id,
off_session: true,
confirm: true
})
success = (stripe_response.status == 'succeeded')
rescue Stripe::CardError => e
# Declined cards, insufficient funds
stripe_response = { error: e.json_body['error'] }
rescue Stripe::RateLimitError => e
# Too many requests
stripe_response = { error: { message: 'Rate limit exceeded', type: 'rate_limit' } }
rescue Stripe::InvalidRequestError => e
# Bad parameters
stripe_response = { error: e.json_body['error'] }
rescue Stripe::AuthenticationError => e
# Bad API key
stripe_response = { error: { message: 'Authentication failed', type: 'authentication' } }
rescue Stripe::APIConnectionError => e
# Network issues
stripe_response = { error: { message: 'Network error', type: 'api_connection' } }
rescue StandardError => e
# Catch-all for unexpected errors
stripe_response = {
error: {
message: e.message,
type: 'unexpected_error',
backtrace: Rails.env.development? ? e.backtrace : nil
}
}
end
# Always create transaction record
transaction = Transaction.create!(
amount_cents: amount_cents,
success: success,
stripe_data: stripe_response.try(:to_hash) || stripe_response,
stripe_payment_id: stripe_response.try(:id),
error_code: success ? nil : extract_error_code(stripe_response),
error_message: success ? nil : extract_error_message(stripe_response)
)
transaction
end
private
def create_zero_transaction
Transaction.create!(
amount_cents: 0,
success: true,
stripe_data: { type: 'zero_amount' }
)
end
def extract_error_code(response)
response.dig('error', 'code') || response.dig('error', 'type')
end
def extract_error_message(response)
response.dig('error', 'message') || 'Unknown error occurred'
end
end
end
Payment Flow Implementation Patterns
Different business scenarios require distinct payment processing patterns, each with specific requirements for timing, error handling, and customer communication, making it essential to implement proven patterns that can be reused and maintained across various payment contexts.
Pattern 1: Subscription Billing
Subscription billing patterns handle the complexities of recurring payments, including trial periods, billing cycles, proration calculations, and the critical requirement to track both successful renewals and failed payment attempts that could lead to service disruption.
# app/services/subscription_billing_service.rb
class SubscriptionBillingService
def initialize(customer, subscription_plan)
@customer = customer
@subscription_plan = subscription_plan
end
def process_monthly_billing
transaction = PaymentService.charge(
@subscription_plan.price_cents,
"Monthly subscription - #{@subscription_plan.name}",
@customer
)
# Always create payment record (success OR failure)
@customer.payment_records.create!(transaction: transaction)
if transaction.success?
extend_subscription
send_receipt_email
else
handle_payment_failure(transaction)
end
transaction
end
private
def handle_payment_failure(transaction)
# Retry logic, notifications, etc.
PaymentFailureNotificationJob.perform_later(@customer.id, transaction.id)
case transaction.error_code
when 'card_declined'
@customer.update!(payment_status: 'declined')
when 'insufficient_funds'
@customer.update!(payment_status: 'insufficient_funds')
else
@customer.update!(payment_status: 'payment_failed')
end
end
end
Pattern 2: E-commerce Order Processing
E-commerce payment patterns focus on immediate transaction processing with tight integration to inventory management, order fulfillment workflows, and the need for real-time payment confirmation before product delivery or service activation.
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :customer
has_many :payment_records, as: :payable
has_many :transactions, through: :payment_records
def process_payment!
transaction = PaymentService.charge(
total_amount_cents,
"Order ##{order_number}",
customer
)
payment_records.create!(transaction: transaction)
if transaction.success?
update!(status: 'paid', paid_at: Time.current)
fulfill_order
else
update!(status: 'payment_failed')
cancel_inventory_hold
end
transaction
end
end
Pattern 3: Digital Product Purchases
Digital product purchase patterns emphasize instant delivery capabilities, handling payment failures gracefully without impacting customer experience, and managing scenarios where payment processing occurs outside the main application flow through webhooks and payment intents.
# app/services/digital_product_purchase_service.rb
class DigitalProductPurchaseService
def self.process_failed_purchase(purchase_params)
stripe_payment = Stripe::PaymentIntent.retrieve(purchase_params[:payment_intent_id])
transaction = Transaction.create!(
amount_cents: purchase_params[:amount_cents],
success: false,
stripe_data: stripe_payment.to_hash,
stripe_payment_id: stripe_payment.id,
error_code: stripe_payment.last_payment_error&.code,
error_message: stripe_payment.last_payment_error&.message
)
# Link to customer if email matches existing user
if purchase_params[:customer_email].present?
customer = Customer.find_by(email: purchase_params[:customer_email])
customer.payment_records.create!(transaction: transaction) if customer
end
transaction
end
end
Building the Admin Dashboard: From Data to Insights
Transforming raw payment data into actionable business intelligence requires careful consideration of both data presentation and system performance. Admin dashboards must balance comprehensive information display with fast load times, while making complex payment details understandable to non-technical business users. The key is creating presenter objects that encapsulate formatting logic, implementing smart caching strategies to handle large datasets efficiently, and designing interfaces that highlight critical information while providing deep-dive capabilities for detailed analysis.
The Challenge: Making Complex Data Accessible
Raw payment data needs transformation for business users. We need to convert this:
A dedicated presenter class encapsulates the complex logic needed to transform raw Stripe API responses into business-friendly display formats, centralizing formatting decisions and providing a clean separation between data processing and view rendering concerns.
# app/presenters/payment_details_presenter.rb
class PaymentDetailsPresenter
def initialize(transaction, view_context)
@transaction = transaction
@view = view_context
end
def status_badge
if @transaction.success?
@view.content_tag(:span, 'SUCCESS',
class: 'badge badge-success')
else
@view.content_tag(:span, 'FAILED',
class: 'badge badge-danger')
end
end
def stripe_dashboard_link
return 'N/A' unless @transaction.stripe_payment_id
@view.link_to(
@view.truncate(@transaction.stripe_payment_id, length: 18),
"https://dashboard.stripe.com/payments/#{@transaction.stripe_payment_id}",
target: '_blank',
class: 'btn btn-sm btn-outline-primary'
)
end
def formatted_amount
@view.number_to_currency(@transaction.amount_cents / 100.0)
end
def error_summary
return 'N/A' if @transaction.success?
error_type = @transaction.error_code&.humanize || 'Unknown'
"#{error_type}: #{@transaction.error_message}"
end
def retry_action_link
return '' if @transaction.success?
@view.link_to('Retry Payment',
@view.retry_payment_path(@transaction),
method: :post,
class: 'btn btn-sm btn-warning',
confirm: 'Are you sure you want to retry this payment?')
end
def documentation_link
return '' if @transaction.success? || @transaction.stripe_data.dig('error', 'doc_url').blank?
@view.link_to('View Docs',
@transaction.stripe_data.dig('error', 'doc_url'),
target: '_blank',
class: 'btn btn-sm btn-info')
end
def receipt_link
return '' unless @transaction.success? &&
@transaction.stripe_data.dig('charges', 'data', 0, 'receipt_url')
@view.link_to('Receipt',
@transaction.stripe_data.dig('charges', 'data', 0, 'receipt_url'),
target: '_blank',
class: 'btn btn-sm btn-secondary')
end
end
Performance Optimization: Caching Strategy
Payment dashboards can quickly become performance bottlenecks as transaction volumes grow, making intelligent caching strategies essential for maintaining responsive user experiences while minimizing expensive API calls and database queries that format payment details.
# app/helpers/admin/payments_helper.rb
module Admin::PaymentsHelper
def cached_payment_details(transaction)
Rails.cache.fetch("payment_details_#{transaction.id}_#{transaction.updated_at.to_i}",
expires_in: 1.hour) do
presenter = PaymentDetailsPresenter.new(transaction, self)
{
status: presenter.status_badge,
amount: presenter.formatted_amount,
stripe_link: presenter.stripe_dashboard_link,
error_summary: presenter.error_summary,
retry_link: presenter.retry_action_link,
docs_link: presenter.documentation_link,
receipt_link: presenter.receipt_link,
created_at: transaction.created_at.strftime('%m/%d/%Y %I:%M %p')
}
end
end
def payment_details_for_display(transaction)
@payment_cache ||= {}
@payment_cache[transaction.id] ||= cached_payment_details(transaction)
end
end
Admin Interface Implementation
Effective admin interface implementation balances information density with usability, providing business users with immediate access to critical payment insights while offering detailed drill-down capabilities that support both operational decision-making and customer support scenarios.
# app/admin/customers.rb (using ActiveAdmin)
ActiveAdmin.register Customer do
show do |customer|
panel "Payment History" do
if customer.payment_records.any?
table_for customer.payment_records.includes(:transaction)
.order(created_at: :desc).limit(50) do
column('Amount') { |pr| payment_details_for_display(pr.transaction)[:amount] }
column('Status') { |pr| payment_details_for_display(pr.transaction)[:status].html_safe }
column('Date') { |pr| payment_details_for_display(pr.transaction)[:created_at] }
column('Stripe ID') { |pr| payment_details_for_display(pr.transaction)[:stripe_link].html_safe }
column('Error Details') { |pr| payment_details_for_display(pr.transaction)[:error_summary] }
column('Actions') do |pr|
details = payment_details_for_display(pr.transaction)
[details[:retry_link], details[:docs_link], details[:receipt_link]]
.select(&:present?).join(' ').html_safe
end
end
else
div "No payment history found.", class: 'text-muted'
end
end
end
end
Stripe API Integration Best Practices
Production Stripe integrations must handle the realities of distributed systems: network failures, rate limits, duplicate requests, and security concerns. Best practices go far beyond basic API usage to include comprehensive webhook verification, idempotency handling that prevents duplicate charges, intelligent retry mechanisms that respect Stripe’s rate limits, and support for complex multi-tenant scenarios through Stripe Connect. These practices ensure your integration remains reliable and secure as your business scales from startup to enterprise levels.
1. Webhook Security and Verification
Webhook security forms the foundation of reliable Stripe integration, ensuring that payment status updates genuinely originate from Stripe and haven’t been tampered with during transmission, protecting your application from malicious actors attempting to manipulate payment states.
# app/controllers/webhooks/stripe_controller.rb
class Webhooks::StripeController < ApplicationController
protect_from_forgery with: :null_session
before_action :verify_webhook_signature
def handle_webhook
event_type = params[:type]
event_data = params[:data][:object]
case event_type
when 'payment_intent.succeeded'
handle_successful_payment(event_data)
when 'payment_intent.payment_failed'
handle_failed_payment(event_data)
when 'customer.subscription.created'
handle_subscription_created(event_data)
when 'invoice.payment_failed'
handle_invoice_payment_failed(event_data)
end
head :ok
end
private
def verify_webhook_signature
payload = request.body.read
signature_header = request.env['HTTP_STRIPE_SIGNATURE']
endpoint_secret = Rails.application.credentials.stripe[:webhook_secret]
begin
Stripe::Webhook.construct_event(payload, signature_header, endpoint_secret)
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
Rails.logger.error "Webhook signature verification failed: #{e.message}"
head :bad_request and return
end
end
def handle_failed_payment(payment_intent)
transaction = Transaction.find_or_create_by(stripe_payment_id: payment_intent['id']) do |t|
t.amount_cents = payment_intent['amount']
t.success = false
t.stripe_data = payment_intent
t.error_code = payment_intent.dig('last_payment_error', 'code')
t.error_message = payment_intent.dig('last_payment_error', 'message')
end
# Find and associate with customer
if payment_intent['customer']
customer = Customer.find_by(stripe_customer_id: payment_intent['customer'])
customer.payment_records.find_or_create_by(transaction: transaction) if customer
end
# Trigger business logic
PaymentFailureHandlerJob.perform_later(transaction.id)
end
end
2. Idempotency for Critical Operations
Idempotency mechanisms prevent the nightmare scenario of duplicate charges caused by network timeouts, user double-clicks, or retry logic, ensuring that each payment intent is processed exactly once regardless of how many times the operation is attempted.
# app/services/idempotent_payment_service.rb
class IdempotentPaymentService
def self.charge_with_idempotency(customer, amount_cents, description, idempotency_key)
# Check if we already processed this request
existing_transaction = Transaction.find_by(idempotency_key: idempotency_key)
return existing_transaction if existing_transaction
begin
payment_intent = Stripe::PaymentIntent.create({
amount: amount_cents,
currency: 'usd',
description: description,
customer: customer.stripe_customer_id,
idempotency_key: idempotency_key # Stripe-level idempotency
})
Transaction.create!(
amount_cents: amount_cents,
success: payment_intent.status == 'succeeded',
stripe_data: payment_intent.to_hash,
stripe_payment_id: payment_intent.id,
idempotency_key: idempotency_key # Application-level idempotency
)
rescue Stripe::IdempotencyError => e
# Stripe detected duplicate request
Rails.logger.warn "Stripe idempotency conflict: #{e.message}"
Transaction.find_by(idempotency_key: idempotency_key)
end
end
end
3. Rate Limiting and Retry Logic
Intelligent rate limiting and retry strategies ensure your application gracefully handles Stripe’s API limits while maintaining service availability, implementing exponential backoff and circuit breaker patterns that prevent cascading failures during high-traffic periods.
# app/services/resilient_payment_service.rb
class ResilientPaymentService
MAX_RETRIES = 3
RETRY_DELAYS = [1.second, 2.seconds, 5.seconds].freeze
def self.charge_with_retries(customer, amount_cents, description)
attempt = 0
begin
attempt += 1
PaymentService.charge(amount_cents, description, customer)
rescue Stripe::RateLimitError => e
if attempt <= MAX_RETRIES
delay = RETRY_DELAYS[attempt - 1] || 5.seconds
Rails.logger.warn "Rate limited, retrying in #{delay}s (attempt #{attempt})"
sleep(delay)
retry
else
# Create failed transaction for rate limit exceeded
Transaction.create!(
amount_cents: amount_cents,
success: false,
stripe_data: { error: { type: 'rate_limit', message: 'Rate limit exceeded after retries' } },
error_code: 'rate_limit_exceeded',
error_message: 'Payment failed due to rate limiting'
)
end
rescue Stripe::APIConnectionError => e
if attempt <= MAX_RETRIES
delay = RETRY_DELAYS[attempt - 1] || 5.seconds
Rails.logger.warn "Network error, retrying in #{delay}s (attempt #{attempt})"
sleep(delay)
retry
else
raise e
end
end
end
end
4. Multi-tenant Stripe Connect Integration
Stripe Connect integration enables platform businesses to process payments on behalf of multiple vendors or service providers, requiring careful handling of split payments, fee calculations, and account permissions while maintaining compliance with financial regulations and platform policies.
# app/services/connect_payment_service.rb
class ConnectPaymentService
def self.charge_connected_account(platform_customer, connected_account_id, amount_cents, description)
begin
payment_intent = Stripe::PaymentIntent.create({
amount: amount_cents,
currency: 'usd',
description: description,
customer: platform_customer.stripe_customer_id,
application_fee_amount: calculate_platform_fee(amount_cents),
transfer_data: {
destination: connected_account_id
}
}, {
stripe_account: connected_account_id # Execute on connected account
})
# Create transaction records for both platform and connected account
Transaction.create!(
amount_cents: amount_cents,
success: payment_intent.status == 'succeeded',
stripe_data: payment_intent.to_hash,
stripe_payment_id: payment_intent.id,
connected_account_id: connected_account_id,
platform_fee_cents: calculate_platform_fee(amount_cents)
)
rescue Stripe::PermissionError => e
# Connected account permissions issue
Transaction.create!(
amount_cents: amount_cents,
success: false,
stripe_data: { error: e.json_body },
error_code: 'permission_error',
error_message: 'Connected account permission denied'
)
end
end
private
def self.calculate_platform_fee(amount_cents)
# 2.9% + $0.30 platform fee
(amount_cents * 0.029 + 30).round
end
end
Key Takeaways and Best Practices
1. Always Track All Payment Attempts
# โ Good: Comprehensive tracking
def process_payment(customer, amount, description)
transaction = PaymentService.charge(amount, description, customer)
customer.payment_records.create!(transaction: transaction) # Always create
if transaction.success?
handle_successful_payment(transaction)
else
handle_failed_payment(transaction)
end
transaction
end
# โ Bad: Selective tracking
def process_payment(customer, amount, description)
transaction = PaymentService.charge(amount, description, customer)
if transaction.success? # Only tracking successes
customer.payment_records.create!(transaction: transaction)
handle_successful_payment(transaction)
end
transaction
end
2. Use Polymorphic Associations for Flexibility
# Allows payments to be associated with any business entity
class PaymentRecord < ApplicationRecord
belongs_to :payable, polymorphic: true # Customer, Order, Subscription, etc.
end
3. Store Complete Stripe Response Data
# Preserve full context for debugging and analytics
Transaction.create!(
amount_cents: amount,
success: payment_successful?,
stripe_data: stripe_response.to_hash, # Complete response
stripe_payment_id: stripe_response.id,
error_code: extract_error_code(stripe_response),
error_message: extract_error_message(stripe_response)
)
4. Build Business-Friendly Interfaces
def payment_status_badge
if transaction.success?
content_tag(:span, 'SUCCESS', class: 'badge badge-success')
else
content_tag(:span, 'FAILED', class: 'badge badge-danger')
end
end
5. Implement Robust Testing
# Test both success and failure scenarios
describe 'payment processing' do
it 'tracks successful payments' do
expect { process_payment }.to change { customer.payment_records.count }.by(1)
expect(customer.transactions.last.success?).to be true
end
it 'tracks failed payments' do
stub_payment_failure
expect { process_payment }.to change { customer.payment_records.count }.by(1)
expect(customer.transactions.last.success?).to be false
end
end
6. Use Caching for Performance
# Cache expensive payment details calculations
def payment_details_for_display(transaction)
Rails.cache.fetch("payment_#{transaction.id}_#{transaction.updated_at.to_i}") do
PaymentDetailsPresenter.new(transaction, self).to_hash
end
end
Conclusion
Building comprehensive payment tracking in Rails applications requires careful attention to data architecture, error handling, and user experience. The patterns demonstrated here provide a foundation for creating payment systems that not only process transactions reliably but also give business teams the visibility they need to understand and optimize their payment flows.
By implementing these patterns, you’ll create payment systems that not only meet immediate business needs but also provide the foundation for future growth and optimization.
A deep dive into race conditions, testing modes, and the mysterious world of background job testing
The Mystery: “But It Works On My Machine!” ๐ค
Picture this: You’ve just refactored some code to improve performance by moving slow operations to background workers. Your tests pass locally with flying colors. You push to CI, feeling confidentโฆ and then:
X expected: 3, got: 2
X expected: 4, got: 0
Welcome to the wonderful world of Sidekiq testing race conditions โ one of the most frustrating debugging experiences in Rails development.
The Setup: A Real-World Example
Let’s examine a real scenario that recently bit us. We had a OrdersWorker that creates orders for new customers:
# app/workers/signup_create_upcoming_orders_worker.rb
class OrdersWorker
include Sidekiq::Worker
def perform(client_id, reason)
client = Client.find(client_id)
# Create orders - this is slow!
client.orders.create
# ... more setup logic
end
end
The worker gets triggered during customer activation:
# lib/settings/update_status.rb
def setup(prev)
# NEW: Move slow operation to background
OrdersWorker.perform_async(@user.client.id, @reason)
# ... other logic
end
And our test helper innocently calls this during setup:
# spec/helper.rb
def init_client(tags = [], sub_menus = nil)
client = FactoryBot.create(:client, ...)
# This triggers the worker!
Settings::Status.new(client, { status: 'active', reason: 'test'}).save
client
end
Understanding Sidekiq Testing Modes
Sidekiq provides three testing modes that behave very differently:
1. Default Mode (Production-like)
# Workers run asynchronously in separate processes
OrdersWorker.perform_async(client.id, 'signup')
# Test continues immediately - worker runs "sometime later"
2. Fake Mode
Sidekiq::Testing.fake!
# Jobs are queued but NOT executed
expect(OrdersWorker.jobs.size).to eq(1)
3. Inline Mode
Sidekiq::Testing.inline!
# Jobs execute immediately and synchronously
OrdersWorker.perform_async(client.id, 'signup')
# ^ This blocks until the job completes
The Environment Plot Twist
Here’s where it gets interesting. The rspec-sidekiq gem can completely override these modes:
Local Development
# Your test output
[rspec-sidekiq] WARNING! Sidekiq will *NOT* process jobs in this environment.
Translation: “I don’t care what Sidekiq::Testing mode you set – workers aren’t running, period.”
CI/Staging
# No warning - workers run normally
Sidekiq 7.3.5 connecting to Redis with options {:url=>"redis://redis:6379/0"}
Translation: “Sidekiq testing modes work as expected.”
The Race Condition Emerges
Now we can see the perfect storm:
RSpec.describe 'OrderBuilder' do
it "calculates order quantities correctly" do
client = init_client([],[]) # * Triggers worker async in CI
client.update!(order_count: 5) # * Sets expected value
order = OrderBuilder.new(client).create(week) # * Reads client state
expect(order.products.first.quantity).to eq(3) # >> Fails in CI
end
end
What happens in CI:
init_client triggers OrdersWorker.perform_async
Test sets order_count = 5
Worker runs asynchronously, potentially resetting client state
# Local: Workers disabled
[rspec-sidekiq] WARNING! Sidekiq will *NOT* process jobs in this environment.
# CI: Workers enabled (no warning)
2. Trace Worker Triggers
Look for these patterns in your test setup:
# Direct calls
SomeWorker.perform_async(...)
# Indirect calls through model callbacks, service objects
client.setup! # May trigger workers internally
Settings::Status.new(...).save # May trigger workers
3. Check for State Mutations
Workers that modify the same data your tests depend on:
# Test expects this value
client.update!(important_field: 'expected_value')
# But worker might reset it
class ProblematicWorker
def perform(client_id)
client = Client.find(client_id)
client.update!(important_field: 'default_value') # ๐ฅ Race condition
end
end
Solutions & Best Practices
Solution 1: File-Level Inline Mode
For specs heavily dependent on worker behavior:
RSpec.describe 'OrderBuilder' do
before(:each) do
# Force all workers to run synchronously
Sidekiq::Testing.inline!
# ... other setup
end
# All tests now have consistent worker behavior
end
Solution 2: Context-Specific Inline Mode
For isolated problematic tests:
context "with background jobs" do
before { Sidekiq::Testing.inline! }
it "works with synchronous workers" do
# Test that needs worker execution
end
end
Solution 3: Stub the Workers
When you don’t need the worker logic:
before do
allow(ProblematicWorker).to receive(:perform_async)
end
Solution 4: Test the Worker Separately
Isolate worker testing from business logic testing:
# Test the worker in isolation
RSpec.describe OrdersWorker do
it "creates orders correctly" do
Sidekiq::Testing.inline!
worker.perform(client.id, 'signup')
expect(client.orders.count).to eq(4)
end
end
# Test business logic without worker interference
RSpec.describe OrderBuilder do
before { allow(OrdersWorker).to receive(:perform_async) }
it "calculates quantities correctly" do
# Pure business logic test
end
end
The Golden Rules
1. Be Explicit About Worker Behavior
Don’t rely on global configuration – be explicit in your tests:
# โ Good: Clear intent
context "with synchronous jobs" do
before { Sidekiq::Testing.inline! }
# ...
end
# โ Bad: Relies on global config
context "testing orders" do
# Assumes some global Sidekiq setting
end
2. Understand Your Test Environment
Know how rspec-sidekiq is configured in each environment:
# config/environments/test.rb
if ENV['CI']
# Allow workers in CI for realistic testing
Sidekiq::Testing.fake!
else
# Disable workers locally for speed
require 'rspec-sidekiq'
end
3. Separate Concerns
Test business logic without worker dependencies
Test worker behavior in isolation
Test integration with controlled worker execution
Real-World Fix
Here’s how we actually solved our issue:
RSpec.describe 'OrderBuilder' do
before(:each) do |example|
# CRITICAL: Ensure Sidekiq workers run synchronously to prevent race conditions
# The init_client helper triggers OrdersWorker via Settings::Status,
# which can modify client state (rte_meal_count) asynchronously in CI, causing test failures.
Sidekiq::Testing.inline!
unless example.metadata[:skip_before]
create_diet_restrictions
create_recipes
assign_recipe_tags
end
end
# All tests now pass consistently in both local and CI! โ
end
Takeaways
Environment Parity Matters: Your local and CI environments may handle Sidekiq differently
Workers Create Race Conditions: Background jobs can interfere with test state
Be Explicit: Don’t rely on global Sidekiq test configuration
Debug Systematically: Look for worker triggers in your test setup
Choose the Right Solution: Inline, fake, or stubbing – pick what fits your test needs
The next time you see tests passing locally but failing in CI, ask yourself: “Are there any background jobs involved?” You might just save yourself hours of debugging! ๐ฏ
Have you encountered similar Sidekiq testing issues? Share your war stories and solutions in the comments below!
Part 1: The Problem – When Legacy Meets Modern Frontend
Our Architecture: A Common Evolution Story
Many web applications today follow a similar evolutionary path. What started as a traditional Rails monolith gradually transforms into a modern hybrid architecture. Our application, let’s call it “MealCorp,” followed this exact journey:
Phase 1: Traditional Rails Monolith
# Traditional Rails serving HTML + embedded JavaScript
class HomeController < ApplicationController
def index
# Rails renders ERB templates with inline scripts
render 'home/index'
end
end
# Modern hybrid: Rails API + Vue frontend
class AppController < ApplicationController
INDEX_PATH = Rails.root.join('public', 'app.html')
INDEX_CONTENT = File.exist?(INDEX_PATH) && File.open(INDEX_PATH, &:read).html_safe
def index
if Rails.env.development?
redirect_to request.url.gsub(':3000', ':5173') # Vite dev server
else
render html: INDEX_CONTENT # Serve built Vue app
end
end
end
The routes configuration looked like this:
# config/routes.rb
Rails.application.routes.draw do
root 'home#index'
get '/dashboard' => 'app#index'
get '/settings' => 'app#index'
get '/profile' => 'app#index'
# Most routes serve the Vue SPA
end
The Hidden Performance Killer
While our frontend was modern and fast, we discovered a critical performance issue that’s common in hybrid architectures. Our Google PageSpeed scores were suffering, showing this alarming breakdown:
// Fix: Add proper type declarations
(window as any)._rollbarConfig = { ... }; // Type assertion approach
// OR declare global types for better type safety
This hybrid architecture optimization demonstrates how modern frontend practices can be retroactively applied to existing applications, achieving significant performance improvements while maintaining full functionality. The key is identifying where legacy server-side patterns conflict with modern client-side performance optimization and implementing targeted solutions.
In Part 1, we learned how to spot bottlenecks using Rails logs. Now let’s go deeper โ using profiling tools, custom logging, and real-world fixes (including our Flipper case).
๐งฐ Profiling Tools for Rails Developers
1. Rack Mini Profiler ๐
Rack Mini Profiler is the go-to gem for spotting slow DB queries and views.
Add it to your Gemfile (development & staging only):
group :development do
gem 'rack-mini-profiler'
end
Then run:
bundle install
When you load a page, youโll see a little timing panel in the top-left corner:
Total time per request.
SQL queries count & time.
Which queries are repeated.
View rendering breakdown.
This makes N+1 queries immediately visible.
2. Bullet ๐ซ (for N+1 Queries)
Add to Gemfile:
group :development do
gem 'bullet'
end
Config in config/environments/development.rb:
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.rails_logger = true
end
Now, if you forget to eager load (includes), Bullet will warn:
N+1 query detected: Review => Product
Add to your query: .includes(:product)
3. Custom Logging for SQL Queries ๐
Sometimes you need to trace where in your code a slow query is triggered. Rails lets you hook into ActiveRecord logging:
# config/initializers/query_tracer.rb
ActiveSupport::Notifications.subscribe("sql.active_record") do |_, start, finish, _, payload|
duration = (finish - start) * 1000
if duration > 100 # log queries > 100ms
Rails.logger.warn "SLOW QUERY (#{duration.round(1)}ms): #{payload[:sql]}"
Rails.logger.warn caller.select { |line| line.include?(Rails.root.to_s) }.first(5).join("\n")
end
end
This logs:
The query.
Execution time.
Stack trace (where in Rails code it was triggered).
โก Real Example: Fixing Flipper Performance
In Part 1, we saw Flipper queries running 38 times per page:
SELECT "flipper_features"."key" AS feature_key,
"flipper_gates"."key",
"flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"
Problem ๐ฅ
Each Flipper.enabled?(:feature_name, user) call hit the DB. With dozens of flags per request โ repeated queries โ 6s page loads.
Solution โ : Redis Caching
Flipper supports caching with Redis.
# config/initializers/flipper.rb
require 'flipper/adapters/active_record'
require 'flipper/adapters/redis'
require 'flipper/adapters/operation_logger'
flipper_db_adapter = Flipper::Adapters::ActiveRecord.new
flipper_redis_adapter = Flipper::Adapters::Redis.new(Redis.new)
# wrap with memory cache to avoid repeat queries
flipper_caching_adapter = Flipper::Adapters::Cache.new(
flipper_db_adapter,
cache: flipper_redis_adapter,
expires_in: 5.minutes
)
Flipper.configure do |config|
config.default = Flipper.new(flipper_caching_adapter)
end
Now:
First request โ fetches from DB, writes to Redis.
Rails makes building apps fast and joyful โ but sooner or later, every team runs into the same dreaded complaint:
“Why is this page so slow?”
Performance debugging is tricky because Rails abstracts so much for us. Underneath every User.where(...).first or current_user.orders.includes(:products), there’s real SQL, database indexes, network calls, caching layers, and Ruby code running.
This post (Part 1) focuses on how to find the bottlenecks in a Rails app using logs and manual inspection. In Part 2, we’ll explore tools like Rack Mini Profiler and real-world fixes.
๐ Symptoms of a Slow Rails Page
Before diving into logs, it’s important to recognize what “slow” might mean:
Page loads take several seconds.
CPU usage spikes during requests.
The database log shows queries running longer than expected.
Repeated queries (e.g. the same SELECT firing 30 times).
Memory bloat or high GC (garbage collection) activity.
Example symptom we hit:
SELECT "flipper_features"."key" AS feature_key,
"flipper_gates"."key",
"flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"
This query was executed 38 times when loading a product page (/product/adidas-shoe). Thatโs a red flag ๐ฉ.
๐ Understanding Rails Logs
Every Rails request is logged in log/development.log (or production.log). A typical request looks like:
Started GET "/products/123" for 127.0.0.1 at 2025-09-25 12:45:01 +0530
Processing by ProductsController#show as HTML
Parameters: {"id"=>"123"}
Product Load (1.2ms) SELECT "products".* FROM "products" WHERE "products"."id" = $1 LIMIT $2 [["id", 123], ["LIMIT", 1]]
Review Load (10.4ms) SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = $1 [["product_id", 123]]
Completed 200 OK in 120ms (Views: 80.0ms | ActiveRecord: 20.0ms | Allocations: 3456)
Key things to notice:
Controller action โ ProductsController#show.
Individual SQL timings โ each query shows how long it took.
If the DB time is small but Views are big โ it’s a rendering problem. If ActiveRecord dominates โ the DB queries are the bottleneck.
๐ต๏ธ Debugging a Slow Page Step by Step
1. Watch your logs in real time
tail -f log/development.log | grep -i "SELECT"
This shows you every SQL query as it executes.
2. Look for repeated queries (N+1)
If you see the same SELECT firing dozens of times:
SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 123
SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 124
SELECT "reviews".* FROM "reviews" WHERE "reviews"."product_id" = 125
That’s the classic N+1 query problem.
3. Look for expensive joins
Queries with multiple JOINs can be slow without proper indexing. Example:
SELECT "orders"."id", "users"."email"
FROM "orders"
INNER JOIN "users" ON "users"."id" = "orders"."user_id"
WHERE "users"."status" = 'active'
If there’s no index on users.status, this can cause sequential scans.
4. Look for long-running queries
Rails logs include timings:
User Load (105.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = 123
If a query consistently takes >100ms on small tables, it probably needs an index or query rewrite.
โก Real Example: Debugging the Flipper Feature Flag Queries
In our case, the Rails logs showed:
SELECT "flipper_features"."key" AS feature_key,
"flipper_gates"."key",
"flipper_gates"."value"
FROM "flipper_features"
LEFT OUTER JOIN "flipper_gates"
ON "flipper_features"."key" = "flipper_gates"."feature_key"
It executed 38 times on one page.
Each execution took between 60โ200ms.
Together, that added ~6 seconds to page load time.
The query itself wasn’t huge (tables had <150 rows). The problem was repetition โ every feature flag check was hitting the DB fresh.
This pointed us toward caching (covered in Part 2).
๐งฉ Workflow for Performance Debugging in Rails
Reproduce the slow page locally or in staging.
Tail the logs and isolate the slow request.
Categorize: rendering slow? DB queries slow? external API calls?
Identify repeated or long queries.
Ask “why“:
Missing index?
Bad join?
N+1 query?
Repeated lookups that could be cached?
Confirm with SQL tools (EXPLAIN ANALYZE in Postgres).
โ Summary of Part 1
In this first part, we covered:
Recognizing symptoms of slow pages.
Reading Rails logs effectively.
Debugging step by step with queries and timings.
A real-world case of repeated Flipper queries slowing down a page.
In Part 2, we’ll go deeper into tools and solutions:
Setting up Rack Mini Profiler.
Capturing queries + stack traces in custom logs.
Applying fixes: indexes, eager loading, and caching (with Flipper as a worked example).