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.

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.