This example shows a payment processing workflow with fraud detection, multiple payment attempts, and comprehensive error handling.
The PaymentProcessingReactor handles complex payment scenarios:
- Validate Payment: Check payment details and amounts
- Fraud Check: Run fraud detection algorithms
- Pre-authorization: Pre-authorize the payment
- Final Charge: Complete the payment
- Record Transaction: Store payment details
- Send Receipt: Email payment confirmation
graph TD
A[Payment Request] --> B[validate_payment]
B --> C{Valid<br/>Payment?}
C -->|No| D[Fail: Validation Error]
C -->|Yes| E[fraud_detection]
E --> F{Fraud<br/>Score OK?}
F -->|No| G[Fail: High Fraud Risk]
F -->|Yes| H[pre_authorize]
H --> I{Pre-auth<br/>Success?}
I -->|No| J[Retry with Backoff]
J --> H
I -->|Yes| K[charge_payment]
K --> L{Charge<br/>Success?}
L -->|No| M[Compensate: Void Pre-auth]
L -->|Yes| N[record_transaction]
N --> O[send_receipt]
O --> P[Success: Payment Complete]
J -->|Max Retries| Q[Fail: Pre-auth Failed]
class PaymentProcessingReactor < RubyReactor::Reactor
async true
# Payment processing needs careful retry configuration
retry_defaults max_attempts: 2, backoff: :fixed, base_delay: 30.seconds
input :amount, validate: -> do
required(:amount).filled(:decimal, gt?: 0)
end
input :currency, validate: -> do
required(:currency).filled(:string, included_in?: ['USD', 'EUR', 'GBP'])
end
input :card_token, validate: -> do
required(:card_token).filled(:string, format?: /^tok_/)
end
step :validate_payment do
argument :amount, input(:amount)
argument :currency, input(:currency)
argument :card_token, input(:card_token)
run do |args, _context|
amount = args[:amount]
currency = args[:currency]
card_token = args[:card_token]
Success({ validated_amount: amount, validated_currency: currency })
end
end
step :fraud_detection do
argument :validation_data, result(:validate_payment)
argument :amount, input(:amount)
argument :card_token, input(:card_token)
run do |args, _context|
amount = args[:amount]
card_token = args[:card_token]
fraud_score = FraudDetectionService.analyze(
amount: amount,
card_token: card_token,
# Additional context...
)
if fraud_score > 0.8
raise "High fraud risk detected (score: #{fraud_score})"
end
Success({ fraud_score: fraud_score, fraud_check_passed: true })
end
end
step :pre_authorize do
argument :fraud_data, result(:fraud_detection)
argument :amount, input(:amount)
argument :currency, input(:currency)
argument :card_token, input(:card_token)
retries max_attempts: 3, backoff: :exponential, base_delay: 5.seconds
run do |args, _context|
amount = args[:amount]
currency = args[:currency]
card_token = args[:card_token]
auth_result = PaymentGateway.pre_authorize(
amount: amount,
currency: currency,
card_token: card_token
)
raise "Pre-authorization failed: #{auth_result.error}" unless auth_result.success?
Success({ auth_id: auth_result.id, auth_amount: amount })
end
undo do |args, _context|
auth_id = args[:fraud_data][:auth_id] || args[:auth_id]
# Void the pre-authorization
PaymentGateway.void_authorization(auth_id) if auth_id
end
end
step :charge_payment do
argument :auth_data, result(:pre_authorize)
# Final charge - critical operation
retries max_attempts: 3, backoff: :fixed, base_delay: 60.seconds
run do |args, _context|
auth_id = args[:auth_data][:auth_id]
amount = args[:auth_data][:auth_amount]
currency = args[:currency]
charge_result = PaymentGateway.charge(
auth_id: auth_id,
amount: amount,
currency: currency
)
raise "Charge failed: #{charge_result.error}" unless charge_result.success?
Success({ charge_id: charge_result.id, charged_amount: amount })
end
undo do |args, _context|
charge_id = args[:auth_data][:charge_id] || args[:charge_id]
# Refund the charge
PaymentGateway.refund(charge_id) if charge_id
end
end
step :record_transaction do
argument :charge_data, result(:charge_payment)
argument :fraud_data, result(:fraud_detection)
argument :amount, input(:amount)
argument :currency, input(:currency)
argument :card_token, input(:card_token)
run do |args, _context|
charge_id = args[:charge_data][:charge_id]
amount = args[:amount]
currency = args[:currency]
card_token = args[:card_token]
fraud_score = args[:fraud_data][:fraud_score]
transaction = PaymentTransaction.create!(
charge_id: charge_id,
amount: amount,
currency: currency,
card_token: card_token,
fraud_score: fraud_score,
status: :completed,
processed_at: Time.current
)
{ transaction_id: transaction.id }
end
compensate do |args, _context|
transaction_id = args[:charge_data][:transaction_id] || args[:transaction_id]
# Mark transaction as failed/refunded
PaymentTransaction.find_by(id: transaction_id)&.update!(status: :refunded)
end
end
step :send_receipt do
argument :transaction_data, result(:record_transaction)
argument :amount, input(:amount)
argument :currency, input(:currency)
retries max_attempts: 3, backoff: :linear, base_delay: 10.seconds
run do |args, _context|
transaction_id = args[:transaction_data][:transaction_id]
amount = args[:amount]
currency = args[:currency]
transaction = PaymentTransaction.find(transaction_id)
customer = transaction.customer
receipt_result = EmailService.send_payment_receipt(
to: customer.email,
transaction: transaction,
amount: amount,
currency: currency
)
raise "Receipt delivery failed" unless receipt_result.success?
{ receipt_sent: true }
end
end
endclass MultiAttemptPaymentReactor < RubyReactor::Reactor
async true
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 10.seconds
step :attempt_primary_card do
argument :order, input(:order)
run do |args, _context|
order = args[:order]
result = process_payment_with_card(order, order.primary_card)
if result.success?
{ payment_result: result, card_used: :primary }
else
{ payment_failed: true, primary_card_failed: true }
end
end
end
step :attempt_backup_card do
argument :primary_attempt, result(:attempt_primary_card)
argument :order, input(:order)
run do |args, _context|
order = args[:order]
primary_card_failed = args[:primary_attempt][:primary_card_failed]
return { skipped: true } unless primary_card_failed
result = process_payment_with_card(order, order.backup_card)
if result.success?
{ payment_result: result, card_used: :backup }
else
raise "All payment attempts failed"
end
end
end
step :process_successful_payment do
argument :attempt_result, result(:attempt_backup_card)
run do |args, _context|
payment_result = args[:attempt_result][:payment_result]
card_used = args[:attempt_result][:card_used]
# Record successful payment
PaymentRecord.create!(
charge_id: payment_result.id,
card_used: card_used,
amount: payment_result.amount
)
{ payment_recorded: true }
end
end
private
def process_payment_with_card(order, card)
PaymentGateway.charge(
amount: order.total,
card_token: card.token,
description: "Order ##{order.id}"
)
end
endclass SubscriptionPaymentReactor < RubyReactor::Reactor
async true
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 1.hour
input :subscription_id, validate: -> do
required(:subscription_id).filled(:string)
end
step :validate_subscription do
argument :subscription_id, input(:subscription_id)
run do |args, _context|
subscription_id = args[:subscription_id]
subscription = Subscription.find(subscription_id)
raise "Subscription not found" unless subscription
raise "Subscription inactive" unless subscription.active?
{ subscription: subscription }
end
end
step :calculate_proration do
argument :validation_data, result(:validate_subscription)
run do |args, _context|
subscription = args[:validation_data][:subscription]
# Calculate prorated amount for billing period
proration = BillingService.calculate_proration(subscription)
{ proration_amount: proration.amount, billing_period: proration.period }
end
end
step :charge_subscription do
argument :validation_data, result(:validate_subscription)
argument :proration_data, result(:calculate_proration)
run do |args, _context|
subscription = args[:validation_data][:subscription]
proration_amount = args[:proration_data][:proration_amount]
billing_period = args[:proration_data][:billing_period]
charge_result = PaymentGateway.charge_subscription(
customer_id: subscription.customer.stripe_id,
amount: proration_amount,
description: "Subscription #{subscription.id} - #{billing_period}"
)
raise "Subscription charge failed" unless charge_result.success?
{ charge_id: charge_result.id }
end
compensate do |args, _context|
charge_id = args[:validation_data][:charge_id] || args[:charge_id]
# Refund the subscription charge
PaymentGateway.refund_subscription_charge(charge_id) if charge_id
end
end
step :update_billing_record do
argument :validation_data, result(:validate_subscription)
argument :proration_data, result(:calculate_proration)
argument :charge_data, result(:charge_subscription)
run do |args, _context|
subscription = args[:validation_data][:subscription]
charge_id = args[:charge_data][:charge_id]
billing_period = args[:proration_data][:billing_period]
BillingRecord.create!(
subscription: subscription,
charge_id: charge_id,
billing_period: billing_period,
status: :paid
)
{ billing_recorded: true }
end
endclass SubscriptionPaymentReactor < RubyReactor::Reactor
async true
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 1.hour
input :subscription_id, validate: -> do
required(:subscription_id).filled(:string)
end
step :validate_subscription do
validate_args do
required(:subscription_id).filled(:string)
end
run do |subscription_id:|
subscription = Subscription.find(subscription_id)
raise "Subscription not found" unless subscription
raise "Subscription inactive" unless subscription.active?
{ subscription: subscription }
end
end
step :calculate_proration do
argument :validation_data, result(:validate_subscription)
run do |args, _context|
subscription = args[:validation_data][:subscription]
# Calculate prorated amount for billing period
proration = BillingService.calculate_proration(subscription)
{ proration_amount: proration.amount, billing_period: proration.period }
end
end
step :charge_subscription do
argument :validation_data, result(:validate_subscription)
argument :proration_data, result(:calculate_proration)
run do |args, _context|
subscription = args[:validation_data][:subscription]
proration_amount = args[:proration_data][:proration_amount]
billing_period = args[:proration_data][:billing_period]
charge_result = PaymentGateway.charge_subscription(
customer_id: subscription.customer.stripe_id,
amount: proration_amount,
description: "Subscription #{subscription.id} - #{billing_period}"
)
raise "Subscription charge failed" unless charge_result.success?
{ charge_id: charge_result.id }
end
compensate do |args, _context|
charge_id = args[:validation_data][:charge_id] || args[:charge_id]
# Refund the subscription charge
PaymentGateway.refund_subscription_charge(charge_id) if charge_id
end
end
step :update_billing_record do
argument :validation_data, result(:validate_subscription)
argument :proration_data, result(:calculate_proration)
argument :charge_data, result(:charge_subscription)
run do |args, _context|
subscription = args[:validation_data][:subscription]
charge_id = args[:charge_data][:charge_id]
billing_period = args[:proration_data][:billing_period]
BillingRecord.create!(
subscription: subscription,
charge_id: charge_id,
billing_period: billing_period,
status: :paid
)
{ billing_recorded: true }
end
end
endclass TimeoutHandlingReactor < RubyReactor::Reactor
step :handle_timeout do
retries max_attempts: 5, backoff: :exponential, base_delay: 2.seconds
run do
begin
Timeout.timeout(10.seconds) do
PaymentGateway.charge(amount: 100, card_token: token)
end
rescue Timeout::Error
raise PaymentTimeoutError.new("Gateway timeout")
end
end
end
end
class PaymentTimeoutError < StandardError
def retryable?
true
end
endclass InsufficientFundsReactor < RubyReactor::Reactor
step :handle_insufficient_funds do
run do
result = PaymentGateway.charge(amount: 100, card_token: token)
if result.error_code == 'insufficient_funds'
# Trigger alternative payment flow
notify_customer_insufficient_funds
raise NonRetryablePaymentError.new("Insufficient funds")
end
result
end
end
end
class NonRetryablePaymentError < StandardError
def retryable?
false
end
endRSpec.describe PaymentProcessingReactor do
let(:valid_payment_params) do
{
amount: 100.00,
currency: 'USD',
card_token: 'tok_1234567890'
}
end
context "successful payment" do
it "completes all payment steps" do
allow(FraudDetectionService).to receive(:analyze).and_return(0.3)
allow(PaymentGateway).to receive(:pre_authorize).and_return(successful_auth)
allow(PaymentGateway).to receive(:charge).and_return(successful_charge)
allow(EmailService).to receive(:send_payment_receipt).and_return(successful_email)
result = PaymentProcessingReactor.run(valid_payment_params)
expect(result).to be_success
expect(result.step_results[:charge_payment][:charge_id]).to be_present
end
end
context "fraud detection" do
it "blocks high-risk payments" do
allow(FraudDetectionService).to receive(:analyze).and_return(0.9)
result = PaymentProcessingReactor.run(valid_payment_params)
expect(result).to be_failure
expect(result.error.message).to include("High fraud risk")
end
end
context "payment gateway failure" do
it "retries pre-authorization" do
allow(FraudDetectionService).to receive(:analyze).and_return(0.3)
allow(PaymentGateway).to receive(:pre_authorize)
.and_raise(PaymentGateway::TimeoutError)
.and_raise(PaymentGateway::TimeoutError)
.and_return(successful_auth)
expect(PaymentGateway).to receive(:pre_authorize).exactly(3).times
result = PaymentProcessingReactor.run(valid_payment_params)
expect(result).to be_success
end
end
context "compensation" do
it "refunds on final charge failure" do
allow(FraudDetectionService).to receive(:analyze).and_return(0.3)
allow(PaymentGateway).to receive(:pre_authorize).and_return(successful_auth)
allow(PaymentGateway).to receive(:charge).and_raise(PaymentGateway::DeclineError)
expect(PaymentGateway).to receive(:void_authorization)
result = PaymentProcessingReactor.run(valid_payment_params)
expect(result).to be_failure
end
end
end# Key metrics for payment processing
PAYMENT_METRICS = {
payment_success_rate: "Percentage of successful payments",
fraud_detection_rate: "Percentage of payments flagged as fraud",
average_payment_time: "Average time to process payment",
retry_rate: "Percentage of payments requiring retries",
chargeback_rate: "Rate of payment chargebacks"
}
# Alerts
PAYMENT_ALERTS = {
high_failure_rate: "Payment success rate below 95%",
high_fraud_rate: "Fraud detection rate above 10%",
slow_processing: "Average payment time above 30 seconds"
}- PCI Compliance: Never log full card details
- Tokenization: Use payment provider tokens, not raw card data
- Idempotency Keys: Prevent duplicate charges
- Rate Limiting: Implement per-customer rate limits
- Audit Logging: Log all payment attempts (without sensitive data)
# Configure payment gateway connection pooling
PaymentGateway.configure do |config|
config.connection_pool_size = 10
config.connection_timeout = 5.seconds
end# Cache fraud detection models
Rails.cache.fetch("fraud_model_#{Date.today}", expires_in: 1.day) do
FraudDetectionService.load_model
end# Use async for non-critical steps
step :send_receipt do
async true # Don't block payment completion on email delivery
run do
# Email sending logic
end
end