How we transformed fragmented payment tracking into a comprehensive admin interface that gives business teams complete visibility into every payment attempt.
Introduction: The Payment Visibility Challenge
When building SaaS applications with complex payment flows, one of the most critical yet overlooked aspects is payment visibility for non-technical teams. While Stripe provides excellent APIs and webhooks, the challenge lies in making this data accessible and actionable for marketing teams, customer success, and business operations.
In this post, we’ll walk through a comprehensive implementation for building robust Stripe payment tracking in a Ruby on Rails application, transforming scattered payment data into a unified admin dashboard that provides complete visibility into every payment attempt—successful or failed.
The Problem: Incomplete Payment Tracking
Common Issues in Production Systems
Many Rails applications suffer from similar payment tracking gaps:
- Selective Tracking: Only successful payments are recorded
- Fragmented Data: Payment attempts scattered across different models
- Poor Error Visibility: Failed payments disappear into the void
- Limited Business Intelligence: No way to analyze payment patterns
Typical Implementation Problems
# Anti-pattern 1: Only tracking successes
def process_subscription_payment(user, amount)
payment = stripe_service.charge(user.stripe_customer_id, amount)
if payment.succeeded?
user.payment_records.create!(
amount: amount,
status: 'success',
stripe_payment_id: payment.id
)
# ❌ Failed payments are lost forever
end
payment
end
# Anti-pattern 2: Missing associations
def charge_customer_wallet(customer, amount, description)
charge = create_stripe_charge(customer, amount, description)
# ❌ Charge created but not linked to customer
Transaction.create!(
amount: amount,
success: charge.succeeded?,
stripe_data: charge.to_hash
)
charge
end
Architecture Deep Dive: Rails + Stripe Integration Patterns
Successful Stripe integration in Rails applications requires more than just API calls—it demands a well-architected system that handles the complexity of payment processing while maintaining clean, maintainable code. The foundation of this architecture lies in polymorphic associations that allow payments to be linked to various business entities (customers, orders, subscriptions), combined with service objects that abstract Stripe’s API complexity and provide consistent error handling. This architectural approach ensures that payment logic remains decoupled from business models while providing the flexibility to support diverse payment scenarios across your application.
The Foundation: Polymorphic Payment Records
Our solution uses a polymorphic association pattern that allows payments to be tracked across different business entities:
# app/models/payment_record.rb
class PaymentRecord < ApplicationRecord
belongs_to :transaction
belongs_to :payable, polymorphic: true # Customer, Order, Subscription, etc.
end
# app/models/customer.rb
class Customer < ApplicationRecord
has_many :payment_records, as: :payable
has_many :transactions, through: :payment_records
def charge(amount_cents, description = 'Payment')
payment_service.charge(amount_cents, description, self)
end
end
# app/models/transaction.rb
class Transaction < ApplicationRecord
# amount_cents: integer
# success: boolean
# stripe_data: jsonb (full Stripe response)
# stripe_payment_id: string
# error_code: string
# error_message: text
scope :successful, -> { where(success: true) }
scope :failed, -> { where(success: false) }
def declined?
!success && stripe_data.dig('error', 'code') == 'card_declined'
end
def error_type
stripe_data.dig('error', 'type')
end
end
The Payment Service: Stripe API Abstraction
A centralized payment service acts as the bridge between your Rails application and Stripe’s API, encapsulating all the complexity of error handling, response processing, and data transformation while providing a clean, consistent interface for the rest of your application to interact with.
# app/services/payment_service.rb
class PaymentService
class << self
def charge(amount_cents, description, customer)
return create_zero_transaction if amount_cents <= 0
success = false
stripe_response = nil
begin
stripe_response = Stripe::PaymentIntent.create({
amount: amount_cents,
currency: 'usd',
description: description,
customer: customer.stripe_customer_id,
payment_method: customer.default_payment_method_id,
off_session: true,
confirm: true
})
success = (stripe_response.status == 'succeeded')
rescue Stripe::CardError => e
# Declined cards, insufficient funds
stripe_response = { error: e.json_body['error'] }
rescue Stripe::RateLimitError => e
# Too many requests
stripe_response = { error: { message: 'Rate limit exceeded', type: 'rate_limit' } }
rescue Stripe::InvalidRequestError => e
# Bad parameters
stripe_response = { error: e.json_body['error'] }
rescue Stripe::AuthenticationError => e
# Bad API key
stripe_response = { error: { message: 'Authentication failed', type: 'authentication' } }
rescue Stripe::APIConnectionError => e
# Network issues
stripe_response = { error: { message: 'Network error', type: 'api_connection' } }
rescue StandardError => e
# Catch-all for unexpected errors
stripe_response = {
error: {
message: e.message,
type: 'unexpected_error',
backtrace: Rails.env.development? ? e.backtrace : nil
}
}
end
# Always create transaction record
transaction = Transaction.create!(
amount_cents: amount_cents,
success: success,
stripe_data: stripe_response.try(:to_hash) || stripe_response,
stripe_payment_id: stripe_response.try(:id),
error_code: success ? nil : extract_error_code(stripe_response),
error_message: success ? nil : extract_error_message(stripe_response)
)
transaction
end
private
def create_zero_transaction
Transaction.create!(
amount_cents: 0,
success: true,
stripe_data: { type: 'zero_amount' }
)
end
def extract_error_code(response)
response.dig('error', 'code') || response.dig('error', 'type')
end
def extract_error_message(response)
response.dig('error', 'message') || 'Unknown error occurred'
end
end
end
Payment Flow Implementation Patterns
Different business scenarios require distinct payment processing patterns, each with specific requirements for timing, error handling, and customer communication, making it essential to implement proven patterns that can be reused and maintained across various payment contexts.
Pattern 1: Subscription Billing
Subscription billing patterns handle the complexities of recurring payments, including trial periods, billing cycles, proration calculations, and the critical requirement to track both successful renewals and failed payment attempts that could lead to service disruption.
# app/services/subscription_billing_service.rb
class SubscriptionBillingService
def initialize(customer, subscription_plan)
@customer = customer
@subscription_plan = subscription_plan
end
def process_monthly_billing
transaction = PaymentService.charge(
@subscription_plan.price_cents,
"Monthly subscription - #{@subscription_plan.name}",
@customer
)
# Always create payment record (success OR failure)
@customer.payment_records.create!(transaction: transaction)
if transaction.success?
extend_subscription
send_receipt_email
else
handle_payment_failure(transaction)
end
transaction
end
private
def handle_payment_failure(transaction)
# Retry logic, notifications, etc.
PaymentFailureNotificationJob.perform_later(@customer.id, transaction.id)
case transaction.error_code
when 'card_declined'
@customer.update!(payment_status: 'declined')
when 'insufficient_funds'
@customer.update!(payment_status: 'insufficient_funds')
else
@customer.update!(payment_status: 'payment_failed')
end
end
end
Pattern 2: E-commerce Order Processing
E-commerce payment patterns focus on immediate transaction processing with tight integration to inventory management, order fulfillment workflows, and the need for real-time payment confirmation before product delivery or service activation.
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :customer
has_many :payment_records, as: :payable
has_many :transactions, through: :payment_records
def process_payment!
transaction = PaymentService.charge(
total_amount_cents,
"Order ##{order_number}",
customer
)
payment_records.create!(transaction: transaction)
if transaction.success?
update!(status: 'paid', paid_at: Time.current)
fulfill_order
else
update!(status: 'payment_failed')
cancel_inventory_hold
end
transaction
end
end
Pattern 3: Digital Product Purchases
Digital product purchase patterns emphasize instant delivery capabilities, handling payment failures gracefully without impacting customer experience, and managing scenarios where payment processing occurs outside the main application flow through webhooks and payment intents.
# app/services/digital_product_purchase_service.rb
class DigitalProductPurchaseService
def self.process_failed_purchase(purchase_params)
stripe_payment = Stripe::PaymentIntent.retrieve(purchase_params[:payment_intent_id])
transaction = Transaction.create!(
amount_cents: purchase_params[:amount_cents],
success: false,
stripe_data: stripe_payment.to_hash,
stripe_payment_id: stripe_payment.id,
error_code: stripe_payment.last_payment_error&.code,
error_message: stripe_payment.last_payment_error&.message
)
# Link to customer if email matches existing user
if purchase_params[:customer_email].present?
customer = Customer.find_by(email: purchase_params[:customer_email])
customer.payment_records.create!(transaction: transaction) if customer
end
transaction
end
end
Building the Admin Dashboard: From Data to Insights
Transforming raw payment data into actionable business intelligence requires careful consideration of both data presentation and system performance. Admin dashboards must balance comprehensive information display with fast load times, while making complex payment details understandable to non-technical business users. The key is creating presenter objects that encapsulate formatting logic, implementing smart caching strategies to handle large datasets efficiently, and designing interfaces that highlight critical information while providing deep-dive capabilities for detailed analysis.
The Challenge: Making Complex Data Accessible
Raw payment data needs transformation for business users. We need to convert this:
{
"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:
| Status | Amount | Error Type | Message | Actions |
|---|---|---|---|---|
| 🔴 FAILED | $49.99 | Insufficient Funds | Card has insufficient funds | View 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.