The Evolution of Stripe’s Payment APIs: From Charges to Payment Intents

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:

requires_payment_method โ†’ requires_confirmation โ†’ requires_action โ†’ succeeded
                       โ†“                      โ†“                 โ†“
                   canceled              canceled          requires_capture
                                                               โ†“
                                                           succeeded

This model elegantly handles scenarios that would break the Charges API:

3D Secure Authentication:

# Payment requires additional authentication
if payment_intent.status == 'requires_action'
  # Frontend handles 3D Secure challenge
  # Webhook confirms completion asynchronously
end

Delayed Capture:

# Authorize now, capture later
payment_intent = Stripe::PaymentIntent.create({
  amount: 2000,
  currency: 'usd',
  payment_method: 'pm_card_visa',
  capture_method: 'manual'  # Authorize only
})

# Later, when ready to fulfill
payment_intent.capture({ amount_to_capture: 1500 })

Key Architectural Changes

1. Separation of Concerns

Payment Intents represent the intent to collect payment and track the payment lifecycle.

Charges become implementation detailsโ€”the actual movement of money that happens within a Payment Intent.

# A successful Payment Intent contains charges
payment_intent = Stripe::PaymentIntent.retrieve('pi_1234567890')
puts payment_intent.charges.data.first.id  # => "ch_0987654321"

2. Enhanced Webhook Events

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:

// Modern client-side payment confirmation
const {error} = await stripe.confirmCardPayment(clientSecret, {
  payment_method: {
    card: cardElement,
    billing_details: {name: 'Jenny Rosen'}
  }
});

if (error) {
  // Handle error
} else {
  // Payment succeeded, redirect to success page
}

Migration Challenges and Solutions

The ID Problem: A Real-World Example

One of the most common migration issues developers face is the ID confusion between Payment Intents and Charges. Here’s a real scenario:

# Legacy refund code expecting charge IDs
def process_refund(charge_id, amount)
  Stripe::Refund.create({
    charge: charge_id,  # Expects ch_xxx
    amount: amount
  })
end

# But Payment Intents return pi_xxx IDs
payment_intent = create_payment_intent(...)
process_refund(payment_intent.id, 500)  # โŒ Fails!

The Solution: Extract the actual charge ID from successful Payment Intents:

def get_charge_id_for_refund(payment_intent)
  if payment_intent.status == 'succeeded'
    payment_intent.charges.data.first.id  # Returns ch_xxx
  else
    raise "Cannot refund unsuccessful payment"
  end
end

# Correct usage
payment_intent = Stripe::PaymentIntent.retrieve('pi_1234567890')
charge_id = get_charge_id_for_refund(payment_intent)
process_refund(charge_id, 500)  # โœ… Works!

Database Schema Evolution

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:

def create_flexible_payment(amount, payment_method_types = ['card'])
  Stripe::PaymentIntent.create({
    amount: amount,
    currency: 'usd',
    payment_method_types: payment_method_types,
    metadata: {
      order_id: @order.id,
      customer_email: @customer.email
    }
  })
end

# Support multiple payment methods
payment_intent = create_flexible_payment(2000, ['card', 'klarna', 'afterpay_clearpay'])

4. Implement Proper Error Handling

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:

# Embedded checkout with Payment Intents
payment_intent = Stripe::PaymentIntent.create({
  amount: 2000,
  currency: 'usd',
  automatic_payment_methods: { enabled: true },
  metadata: { integration_check: 'accept_a_payment' }
})

2. Real-Time Payments

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

  1. Payment Intents are the future: If you’re building new payment functionality, start with Payment Intents, not Charges.
  2. Embrace asynchronous patterns: Don’t expect payments to complete immediately. Design your system around webhooks and state management.
  3. Handle the ID confusion: Remember that Payment Intents (pi_) contain Charges (ch_). Refunds and some other operations still work on charge IDs.
  4. Implement robust webhook handling: With complex payment flows, webhooks become critical infrastructure, not nice-to-have features.
  5. Test thoroughly: The increased complexity of Payment Intents requires more comprehensive testing, especially around authentication flows and edge cases.
  6. 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.

๐Ÿ” Understanding Rubyโ€™s Singleton Class: Why We Open the Eigenclass at the Class Level – Advanced

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 ]

For a class object like Payment:

[ Payment ] ---> [ Payment's Eigenclass ] ---> [ 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.


Happy Ruby coding!

๐Ÿ” Understanding Why Ruby Opens the Singleton (Eigenclass) at the Class Level

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).

4. Override class-level behavior (new, allocate, etc.)

You must use the eigenclass for these methods:

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.


Happy Coding!

Building Robust Stripe Payment Tracking in Rails: Rspec Testing, Advanced Implementation Patterns, Reporting – part 2

How we transformed fragmented payment tracking into a comprehensive admin interface that gives business teams complete visibility into every payment attempt.

This post follows the part 1 of stripe implementation we have seen. Stripe Payment – Part 1

Testing Strategy: Comprehensive Payment Testing

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.

# app/services/payment_analytics_service.rb
class PaymentAnalyticsService
  def self.daily_payment_metrics(date = Date.current)
    transactions = Transaction.where(created_at: date.beginning_of_day..date.end_of_day)

    {
      total_attempts: transactions.count,
      successful_payments: transactions.successful.count,
      failed_payments: transactions.failed.count,
      success_rate: calculate_success_rate(transactions),
      total_volume: transactions.successful.sum(:amount_cents),
      average_transaction: calculate_average_amount(transactions.successful),
      top_failure_reasons: top_failure_reasons(transactions.failed),
      decline_by_card_type: decline_breakdown_by_card(transactions.failed)
    }
  end

  def self.customer_payment_health(customer)
    recent_transactions = customer.transactions
                                 .where(created_at: 30.days.ago..Time.current)
                                 .order(created_at: :desc)

    {
      total_transactions: recent_transactions.count,
      success_rate: calculate_success_rate(recent_transactions),
      consecutive_failures: calculate_consecutive_failures(recent_transactions),
      days_since_last_success: days_since_last_success(recent_transactions),
      risk_score: calculate_risk_score(recent_transactions)
    }
  end

  private

  def self.calculate_success_rate(transactions)
    return 0 if transactions.empty?
    (transactions.successful.count.to_f / transactions.count * 100).round(2)
  end

  def self.top_failure_reasons(failed_transactions)
    failed_transactions.group(:error_code)
                      .count
                      .sort_by { |_, count| -count }
                      .first(5)
                      .to_h
  end
end

2. Automated Payment Recovery

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.


Building Robust Stripe Payment Tracking in Rails: From API Integration to Admin Dashboard Excellence – part 1

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:

  1. Selective Tracking: Only successful payments are recorded
  2. Fragmented Data: Payment attempts scattered across different models
  3. Poor Error Visibility: Failed payments disappear into the void
  4. 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:

{
  "id": "pi_1H7XYZabcd123456", 
  "status": "requires_payment_method",
  "last_payment_error": {
    "code": "card_declined",
    "decline_code": "insufficient_funds", 
    "message": "Your card has insufficient funds."
  }
}

Into this business-friendly format:

StatusAmountError TypeMessageActions
๐Ÿ”ด FAILED$49.99Insufficient FundsCard has insufficient fundsView Stripe Retry

The PaymentDetailsPresenter Class

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.


Sidekiq Testing Gotchas: When Your Tests Pass Locally But Fail in CI

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:

  1. init_client triggers OrdersWorker.perform_async
  2. Test sets order_count = 5
  3. Worker runs asynchronously, potentially resetting client state
  4. OrderBuilder reads modified/stale client data
  5. Calculations use wrong values โ†’ test fails

What happens locally:

  1. init_client triggers worker (but rspec-sidekiq blocks it)
  2. Test sets order_count = 5
  3. No worker interference
  4. OrderBuilder reads correct client data
  5. Test passes โœ…

Debugging Strategies

1. Look for the Warning

# 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

  1. Environment Parity Matters: Your local and CI environments may handle Sidekiq differently
  2. Workers Create Race Conditions: Background jobs can interfere with test state
  3. Be Explicit: Don’t rely on global Sidekiq test configuration
  4. Debug Systematically: Look for worker triggers in your test setup
  5. 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!

๐Ÿš€ Optimizing Third-Party Script Loading in a Rails + Vue Hybrid Architecture

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

Phase 2: Hybrid Rails + Vue Architecture (Current State)

# 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:

JavaScript Execution Time Analysis:

Reduce JavaScript execution time: 1.7s
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Script                           โ”‚ Total โ”‚ Evaluation โ”‚ Parse โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ Google Tag Manager              โ”‚ 615ms โ”‚    431ms   โ”‚ 171ms โ”‚
โ”‚ Rollbar Error Tracking          โ”‚ 258ms โ”‚    218ms   โ”‚  40ms โ”‚
โ”‚ Facebook SDK                    โ”‚ 226ms โ”‚    155ms   โ”‚  71ms โ”‚
โ”‚ Main Application Bundle         โ”‚ 190ms โ”‚    138ms   โ”‚  52ms โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The smoking gun? Third-party monitoring scripts were consuming more execution time than our actual application!

Investigating the Mystery

The puzzle deepened when we compared our source files:

Vue Frontend Source (index.html):

<pre class="wp-block-syntaxhighlighter-code"><!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>MealCorp Dashboard</title>
    <!-- Clean, minimal head section -->
    <a href="https://js.stripe.com/v3">https://js.stripe.com/v3</a>
    <a href="https://kit.fontawesome.com/abc123.js">https://kit.fontawesome.com/abc123.js</a>
  </head>
  <body>
    <div id="app"></div>
    <a href="/src/main.ts">/src/main.ts</a>
  </body>
</html></pre>

Built Static File (public/app.html):

<pre class="wp-block-syntaxhighlighter-code"><!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>MealCorp Dashboard</title>
    <!-- Same clean content, no third-party scripts -->
    <a href="/assets/index-xyz123.js">/assets/index-xyz123.js</a>
    <link rel="stylesheet" crossorigin href="/assets/index-abc456.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html></pre>

But Browser “View Source” Showed:

<pre class="wp-block-syntaxhighlighter-code"><!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>MealCorp Dashboard</title>

    <!-- Mystery scripts appearing from nowhere! -->
    <script>var _rollbarConfig = {"accessToken":"token123...","captureUncaught":true...}</script>
    <script>!function(r){var e={}; /* Minified Rollbar library */ }</script>

    <script>(function(w,d,s,l,i){w[l]=w[l]||[]; /* GTM script */ })(window,document,'script','dataLayer','GTM-ABC123');</script>

    <!-- Our clean application code -->
    <a href="/assets/index-xyz123.js">/assets/index-xyz123.js</a>
  </body>
</html></pre>

The Root Cause Discovery

After investigation, we discovered that Rails was automatically injecting third-party scripts at runtime, despite serving static files!

Here’s what was happening in our Rails configuration:

Google Tag Manager Configuration:

# config/initializers/analytics.rb (Old problematic approach)
# This was loading synchronously in the Rails asset pipeline

Rollbar Configuration:

# config/initializers/rollbar.rb
Rollbar.configure do |config|
  config.access_token = 'server_side_token_123'

  # The culprit: Automatic JavaScript injection!
  config.js_enabled = true  # X This caused performance issues
  config.js_options = {
    accessToken: Rails.application.credentials[Rails.env.to_sym][:rollbar_client_token],
    captureUncaught: true,
    payload: { environment: Rails.env },
    hostSafeList: ['example.com', 'staging.example.com']
  }
end

The Request Flow That Caused Our Performance Issues:

  1. Browser requests /dashboard
  2. Rails routes to AppController#index
  3. Rails renders static public/app.html content
  4. Rollbar gem automatically injects JavaScript into the HTML response
  5. GTM configuration adds synchronous tracking scripts
  6. Browser receives HTML with blocking third-party scripts
  7. Performance suffers due to synchronous execution

Part 2: The Solution – Modern Deferred Loading

Understanding the Performance Impact

The core issue was synchronous script execution during page load. Each third-party service was blocking the main thread:

// What was happening (blocking):
<script>
  var _rollbarConfig = {...}; // Immediate execution - blocks rendering
</script>
<script>
  (function(w,d,s,l,i){ // GTM immediate execution - blocks rendering
    // Heavy synchronous operations
  })(window,document,'script','dataLayer','GTM-ABC123');
</script>

The Modern Solution: Deferred Loading Architecture

We implemented a Vue-based deferred loading system that maintains identical functionality while dramatically improving performance.

Step 1: Disable Rails Auto-Injection

# config/initializers/rollbar.rb
Rollbar.configure do |config|
  config.access_token = 'server_side_token_123'

  # Disable automatic JavaScript injection for better performance
  config.js_enabled = false  # Good - Stop Rails from injecting scripts

  # Server-side error tracking remains unchanged
  config.person_method = "current_user"
  # ... other server-side config
end

Step 2: Implement Vue-Based Deferred Loading

// src/App.vue
<script setup lang="ts">
import { onMounted } from 'vue';

// Load third-party scripts after Vue app mounts for better performance  
onMounted(() => {
  loadGoogleTagManager();
  loadRollbarDeferred();
});

function loadGoogleTagManager() {
  const script = document.createElement('script');
  script.async = true;
  script.src = `https://www.googletagmanager.com/gtm.js?id=${import.meta.env.VITE_GTM_ID}`;

  // Track initial pageview once GTM loads
  script.onload = () => {
    trackEvent({
      event: 'page_view',
      page_title: document.title,
      page_location: window.location.href,
      page_path: window.location.pathname
    });
  };

  document.head.appendChild(script);
}

function loadRollbarDeferred() {
  const rollbarToken = import.meta.env.VITE_ROLLBAR_CLIENT_TOKEN;
  if (!rollbarToken) return;

  // Load after all other resources are complete
  window.addEventListener('load', () => {
    // Initialize Rollbar configuration
    (window as any)._rollbarConfig = {
      accessToken: rollbarToken,
      captureUncaught: true,
      payload: {
        environment: import.meta.env.MODE // 'production', 'staging', etc.
      },
      hostSafeList: ['example.com', 'staging.example.com']
    };

    // Load Rollbar script asynchronously
    const rollbarScript = document.createElement('script');
    rollbarScript.async = true;
    rollbarScript.src = 'https://cdn.rollbar.com/rollbarjs/refs/tags/v2.26.1/rollbar.min.js';
    document.head.appendChild(rollbarScript);
  });
}
</script>

Step 3: TypeScript Support

// src/types/global.d.ts
declare global {
  interface Window {
    _rollbarConfig?: {
      accessToken: string;
      captureUncaught: boolean;
      payload: {
        environment: string;
      };
      hostSafeList: string[];
    };
    dataLayer?: any[];
  }
}

export {};

Environment Configuration

# .env.production
VITE_GTM_ID=GTM-PROD123
VITE_ROLLBAR_CLIENT_TOKEN=client_token_prod_456

# .env.staging  
VITE_GTM_ID=GTM-STAGING789
VITE_ROLLBAR_CLIENT_TOKEN=client_token_staging_789

Testing the Implementation

Comprehensive Testing Script:

// Browser console testing function
function testTrackingImplementation() {
  console.log('=== TRACKING SYSTEM TEST ===');

  // Test 1: GTM Integration
  console.log('GTM dataLayer exists:', !!window.dataLayer);
  console.log('GTM script loaded:', !!document.querySelector('script[src*="googletagmanager.com"]'));
  console.log('Recent GTM events:', window.dataLayer?.slice(-3));

  // Test 2: Rollbar Integration  
  console.log('Rollbar loaded:', typeof Rollbar !== 'undefined');
  console.log('Rollbar config:', window._rollbarConfig);
  console.log('Rollbar script loaded:', !!document.querySelector('script[src*="rollbar"]'));

  // Test 3: Send Test Events
  // GTM Test Event
  window.dataLayer?.push({
    event: 'test_tracking',
    test_source: 'manual_verification',
    timestamp: new Date().toISOString()
  });

  // Rollbar Test Error
  if (typeof Rollbar !== 'undefined') {
    Rollbar.error('Test error for verification - please ignore', {
      test_context: 'performance_optimization_verification'
    });
  }

  console.log('โœ… Test events sent - check dashboards in 1-2 minutes');
}

// Run the test
testTrackingImplementation();

Expected Console Output:

=== TRACKING SYSTEM TEST ===
GTM dataLayer exists: true
GTM script loaded: true
Recent GTM events: [
  {event: "page_view", page_title: "Dashboard", ...},
  {event: "gtm.dom", ...}, 
  {event: "gtm.load", ...}
]
Rollbar loaded: true
Rollbar config: {accessToken: "...", captureUncaught: true, ...}
Rollbar script loaded: true
โœ… Test events sent - check dashboards in 1-2 minutes

Performance Results

Before Optimization:

JavaScript Execution Time: 1.7s
โ”œโ”€โ”€ Google Tag Manager: 615ms (synchronous)
โ”œโ”€โ”€ Rollbar: 258ms (synchronous)  
โ”œโ”€โ”€ Facebook SDK: 226ms (synchronous)
โ””โ”€โ”€ Application Code: 190ms

After Optimization:

JavaScript Execution Time: 0.4s
โ”œโ”€โ”€ Application Code: 190ms (immediate)
โ”œโ”€โ”€ Deferred Scripts: ~300ms (non-blocking, post-load)
โ””โ”€โ”€ Performance Improvement: ~1.3s (76% reduction)

Key Benefits Achieved

  1. Performance Gains:
  • 76% reduction in blocking JavaScript execution time
  • Improved Core Web Vitals scores
  • Better user experience with faster perceived load times
  1. Maintained Functionality:
  • Identical error tracking capabilities
  • Same analytics data collection
  • All monitoring dashboards continue working
  1. Better Architecture:
  • Modern Vue-based script management
  • Environment-specific configuration
  • TypeScript support for better maintainability
  1. Security Improvements:
  • Proper separation of server vs. client tokens
  • Environment variable management
  • No sensitive data in version control

Common Pitfalls and Solutions

Issue 1: Token Confusion

Error: post_client_item scope required but token has post_server_item

Solution: Use separate client-side tokens for browser JavaScript.

Issue 2: Missing Initial Pageviews
Solution: Implement manual pageview tracking in script.onload callback.

Issue 3: TypeScript Errors

// 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.


Happy Optimization! ๐Ÿš€

๐Ÿ› ๏ธ Classic Performance Debugging Problems in Rails Apps โ€” Part 2: Tools & Fixes

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.
  • Next requests โ†’ served from Redis/memory.
  • Page load dropped from 6s โ†’ <500ms ๐ŸŽ‰.

Extra: Inspecting Redis Keys ๐Ÿ”

Check what’s being cached:

Rails.cache.redis.keys("*flipper*")

Or, if using connection pooling:

Rails.cache.redis.with { |r| r.keys("*flipper*") }


๐Ÿ—๏ธ Other Common Fixes

1. Add Missing Indexes

If you see slow queries with filters:

SELECT * FROM orders WHERE user_id = 123

Fix:

rails generate migration add_index_to_orders_user_id
rails db:migrate

2. Eager Load Associations

Instead of:

@orders = current_user.orders
@orders.each do |order|
  puts order.user.email
end

Fix N+1:

@orders = current_user.orders.includes(:user)

3. Memoization / Request Caching

If you call the same method multiple times per request:

def expensive_query
  User.where(active: true).to_a
end

Fix:

def expensive_query
  @expensive_query ||= User.where(active: true).to_a
end

๐Ÿ“Œ Summary of Part 2

In this part, we covered:

  • Using tools: Rack Mini Profiler, Bullet, and custom logging.
  • A real-world Flipper caching fix (DB โ†’ Redis).
  • Other fixes: indexes, eager loading, memoization.

Together, these tools + fixes turn performance debugging from guesswork into a repeatable workflow.

๐Ÿ“Œ In Part 3, we’ll go even deeper into advanced techniques:

  • EXPLAIN ANALYZE in Postgres.
  • Profiling memory allocations with stackprof.
  • Using Skylight or Datadog in production.

๐Ÿšฆ Classic Performance Debugging Problems in Rails Apps โ€” Part 1: Finding the Bottlenecks

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.
  • Overall time โ†’ Completed 200 OK in 120ms.
  • Breakdown โ†’ Views: 80.0ms | ActiveRecord: 20.0ms.

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

  1. Reproduce the slow page locally or in staging.
  2. Tail the logs and isolate the slow request.
  3. Categorize: rendering slow? DB queries slow? external API calls?
  4. Identify repeated or long queries.
  5. Ask “why“:
    • Missing index?
    • Bad join?
    • N+1 query?
    • Repeated lookups that could be cached?
  6. 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).

Part 4 – Redis, cache invalidation, testing, pitfalls, and checklist

10) Optional: Redis caching in a Rails API app (why and how)

Even in API-only apps, application-level caching is useful to reduce DB load for expensive queries or aggregated endpoints.

Common patterns

  • Low-level caching: Rails.cache.fetch
  • Fragment caching: less relevant for API-only (used for views), but you can cache JSON blobs
  • Keyed caching with expiration for computed results

Example โ€” caching an expensive query

class Api::V1::ReportsController < ApplicationController
  def sales_summary
    key = "sales_summary:#{Time.current.utc.strftime('%Y-%m-%d-%H')}"
    data = Rails.cache.fetch(key, expires_in: 5.minutes) do
      # expensive computation
      compute_sales_summary
    end
    render json: data
  end
end

Why Redis?

  • Redis is fast, supports expirations, and can be used as Rails.cache store (config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'] }).
  • Works well for ephemeral caches that you want to expire automatically.

Invalidation strategies

  • Time-based (TTL) โ€” simplest.
  • Key-based โ€” when related data changes, evict related keys.
    • Example: after a product update, call Rails.cache.delete("top_offers").
  • Versioned keys โ€” embed a version or timestamp in the key (e.g., products:v3:top) and bump the version on deploy/major change.
  • Tagging / Key sets โ€” maintain a set of keys per resource to delete them on change (more manual).

Caveat: Don’t rely solely on Redis caching for user-specific sensitive data. Use private caches when needed.

11) Purging caches and CDN invalidation

When hashed assets are used you rarely need to purge because new filenames mean new URLs. For non-hashed assets or CDN caches you may need purge:

  • CDN invalidation: Cloudflare / Fastly / CloudFront offer purge by URL or cache key. Use CDN APIs or surrogate-key headers to do group purges.
  • Surrogate-Control / Surrogate-Key (Fastly): set headers that help map objects to tags for efficient purging.
  • Nginx cache purge: if you configure proxy_cache, you must implement purge endpoints or TTLs.

Recommended approach:

  • Prefer hashed filenames for assets so you rarely need invalidation.
  • For dynamic content, prefer short TTLs or implement programmatic CDN purges as part of deploy/administration flows.

12) Testing and verifying caching behavior (practical commands)

Check response headers

curl -I https://www.mydomain.com/vite/index-B34XebCm.js
# expect: Cache-Control: public, max-age=31536000, immutable

Conditional request test (ETag)

  1. Get ETag:
curl -I https://api.mydomain.com/api/v1/products/123
# Look for ETag: "..."

  1. Re-request with that ETag:
curl -I -H 'If-None-Match: "the-etag-value"' https://api.mydomain.com/api/v1/products/123
# expect: 304 Not Modified (if unchanged)

Check s-maxage / CDN effect

  • Use curl -I against the CDN domain (if applicable) to inspect Age header (shows time cached at edge) and X-Cache headers from CDN.

Chrome DevTools

  • Open Network tab, reload page, inspect a cached resource:
    • Status might show (from disk cache) or (from memory cache) if cached.
    • For resources with max-age but no immutable, you might see 200 with from disk cache or network requests with 304.

13) Common pitfalls and how to avoid them

  1. Caching HTML
    • Problem: If your index.html or vite.html is cached, users can get old asset references and see broken UI.
    • Avoid: Always no-cache your HTML entry file.
  2. Caching non-hashed assets long-term
    • Problem: Logo or content images may not update for users.
    • Avoid: Short TTL or rename files when updating (versioning).
  3. Not using ETag/Last-Modified
    • Problem: Clients re-download entire payloads when unchanged โ€” wasted bandwidth.
    • Avoid: Use ETag or Last-Modified so clients can get 304.
  4. Caching user-specific responses publicly
    • Problem: Data leak (private data served to other users).
    • Avoid: Use private/no-store for user-specific responses.
  5. Relying solely on Nginx for dynamic caching
    • Problem: Hard to maintain invalidations and complex to configure with Passenger.
    • Avoid: Use Rails headers + CDN; or a caching reverse proxy only if necessary and you know how to invalidate.

14) Commands and operational notes for Passenger

Test nginx config

sudo nginx -t

Reload nginx gracefully

sudo systemctl reload nginx

Restart nginx if reload fails

sudo systemctl restart nginx

Restart Passenger (app-level)

  • Passenger allows restarting an app without touching the systemd service:
# restart specific app by path
sudo passenger-config restart-app /apps/mydomain/current

  • Or restart all apps (be careful):
sudo passenger-config restart-app --ignore-app-not-running

Check Passenger status

passenger-status

Always run nginx -t before reload. Make sure to test caching headers after a reload (curl) before rolling out.

15) Final checklist before/after deploy (practical)

Before deploy:

  • Ensure build pipeline fingerprints Vite output (hash in filenames).
  • Ensure /apps/mydomain/current/public/vite/ contains hashed assets.
  • Confirm vite.html is correct and references the hashed file names.
  • Confirm Nginx snippet for /vite/ long cache is present and not overridden.

After deploy:

  • Run sudo nginx -t and sudo systemctl reload nginx.
  • Test hashed asset: curl -I https://www.mydomain.com/vite/index-...js โ†’ Cache-Control: public, max-age=31536000
  • Test HTML: curl -I https://www.mydomain.com/vite.html โ†’ Cache-Control: no-cache
  • Test sample API endpoint headers: curl -I https://api.mydomain.com/api/v1/products โ†’ verify Cache-Control and ETag/Last-Modified where applicable.
  • Run smoke tests in browser (Chrome DevTools) to verify resources are cached as expected.

16) Appendix โ€” example Rails snippets (summary)

Set header manually

response.set_header('Cache-Control', 'public, max-age=60, s-maxage=300')

Return 304 using conditional GET

def show
  resource = Resource.find(params[:id])
  if stale?(etag: resource, last_modified: resource.updated_at)
    render json: resource
  end
end

Redis caching (Rails.cache)

data = Rails.cache.fetch("top_products_page_#{params[:page]}", expires_in: 5.minutes) do
  Product.top.limit(20).to_a
end
render json: data

Conclusion (Part 4)

This part explained where caching belongs in an API-only Rails + Vue application, how Passenger fits into the stack, how to set cache headers for safe API caching, optional Redis caching strategies, and practical testing/operational steps.