ruby_reactor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/README.md +570 -0
- data/Rakefile +12 -0
- data/documentation/DAG.md +457 -0
- data/documentation/README.md +123 -0
- data/documentation/async_reactors.md +369 -0
- data/documentation/composition.md +199 -0
- data/documentation/core_concepts.md +662 -0
- data/documentation/data_pipelines.md +224 -0
- data/documentation/examples/inventory_management.md +749 -0
- data/documentation/examples/order_processing.md +365 -0
- data/documentation/examples/payment_processing.md +654 -0
- data/documentation/getting_started.md +224 -0
- data/documentation/retry_configuration.md +357 -0
- data/lib/ruby_reactor/async_router.rb +91 -0
- data/lib/ruby_reactor/configuration.rb +41 -0
- data/lib/ruby_reactor/context.rb +169 -0
- data/lib/ruby_reactor/context_serializer.rb +164 -0
- data/lib/ruby_reactor/dependency_graph.rb +126 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
- data/lib/ruby_reactor/dsl/reactor.rb +151 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
- data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
- data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
- data/lib/ruby_reactor/error/base.rb +16 -0
- data/lib/ruby_reactor/error/compensation_error.rb +8 -0
- data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
- data/lib/ruby_reactor/error/dependency_error.rb +8 -0
- data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
- data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
- data/lib/ruby_reactor/error/undo_error.rb +8 -0
- data/lib/ruby_reactor/error/validation_error.rb +8 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
- data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
- data/lib/ruby_reactor/executor/input_validator.rb +39 -0
- data/lib/ruby_reactor/executor/result_handler.rb +103 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
- data/lib/ruby_reactor/executor/step_executor.rb +319 -0
- data/lib/ruby_reactor/executor.rb +123 -0
- data/lib/ruby_reactor/map/collector.rb +65 -0
- data/lib/ruby_reactor/map/element_executor.rb +154 -0
- data/lib/ruby_reactor/map/execution.rb +60 -0
- data/lib/ruby_reactor/map/helpers.rb +67 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
- data/lib/ruby_reactor/reactor.rb +75 -0
- data/lib/ruby_reactor/retry_context.rb +92 -0
- data/lib/ruby_reactor/retry_queued_result.rb +26 -0
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
- data/lib/ruby_reactor/step/compose_step.rb +107 -0
- data/lib/ruby_reactor/step/map_step.rb +234 -0
- data/lib/ruby_reactor/step.rb +33 -0
- data/lib/ruby_reactor/storage/adapter.rb +51 -0
- data/lib/ruby_reactor/storage/configuration.rb +15 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
- data/lib/ruby_reactor/template/base.rb +15 -0
- data/lib/ruby_reactor/template/element.rb +25 -0
- data/lib/ruby_reactor/template/input.rb +48 -0
- data/lib/ruby_reactor/template/result.rb +48 -0
- data/lib/ruby_reactor/template/value.rb +22 -0
- data/lib/ruby_reactor/validation/base.rb +26 -0
- data/lib/ruby_reactor/validation/input_validator.rb +62 -0
- data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
- data/lib/ruby_reactor/version.rb +5 -0
- data/lib/ruby_reactor.rb +159 -0
- data/sig/ruby_reactor.rbs +4 -0
- metadata +178 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
# Payment Processing Reactor Example
|
|
2
|
+
|
|
3
|
+
This example shows a payment processing workflow with fraud detection, multiple payment attempts, and comprehensive error handling.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The PaymentProcessingReactor handles complex payment scenarios:
|
|
8
|
+
|
|
9
|
+
1. **Validate Payment**: Check payment details and amounts
|
|
10
|
+
2. **Fraud Check**: Run fraud detection algorithms
|
|
11
|
+
3. **Pre-authorization**: Pre-authorize the payment
|
|
12
|
+
4. **Final Charge**: Complete the payment
|
|
13
|
+
5. **Record Transaction**: Store payment details
|
|
14
|
+
6. **Send Receipt**: Email payment confirmation
|
|
15
|
+
|
|
16
|
+
### Payment Processing Workflow
|
|
17
|
+
|
|
18
|
+
```mermaid
|
|
19
|
+
graph TD
|
|
20
|
+
A[Payment Request] --> B[validate_payment]
|
|
21
|
+
B --> C{Valid<br/>Payment?}
|
|
22
|
+
C -->|No| D[Fail: Validation Error]
|
|
23
|
+
C -->|Yes| E[fraud_detection]
|
|
24
|
+
E --> F{Fraud<br/>Score OK?}
|
|
25
|
+
F -->|No| G[Fail: High Fraud Risk]
|
|
26
|
+
F -->|Yes| H[pre_authorize]
|
|
27
|
+
H --> I{Pre-auth<br/>Success?}
|
|
28
|
+
I -->|No| J[Retry with Backoff]
|
|
29
|
+
J --> H
|
|
30
|
+
I -->|Yes| K[charge_payment]
|
|
31
|
+
K --> L{Charge<br/>Success?}
|
|
32
|
+
L -->|No| M[Compensate: Void Pre-auth]
|
|
33
|
+
L -->|Yes| N[record_transaction]
|
|
34
|
+
N --> O[send_receipt]
|
|
35
|
+
O --> P[Success: Payment Complete]
|
|
36
|
+
|
|
37
|
+
J -->|Max Retries| Q[Fail: Pre-auth Failed]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Implementation
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
class PaymentProcessingReactor < RubyReactor::Reactor
|
|
44
|
+
async true
|
|
45
|
+
|
|
46
|
+
# Payment processing needs careful retry configuration
|
|
47
|
+
retry_defaults max_attempts: 2, backoff: :fixed, base_delay: 30.seconds
|
|
48
|
+
|
|
49
|
+
input :amount, validate: -> do
|
|
50
|
+
required(:amount).filled(:decimal, gt?: 0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
input :currency, validate: -> do
|
|
54
|
+
required(:currency).filled(:string, included_in?: ['USD', 'EUR', 'GBP'])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
input :card_token, validate: -> do
|
|
58
|
+
required(:card_token).filled(:string, format?: /^tok_/)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
step :validate_payment do
|
|
62
|
+
argument :amount, input(:amount)
|
|
63
|
+
argument :currency, input(:currency)
|
|
64
|
+
argument :card_token, input(:card_token)
|
|
65
|
+
|
|
66
|
+
run do |args, _context|
|
|
67
|
+
amount = args[:amount]
|
|
68
|
+
currency = args[:currency]
|
|
69
|
+
card_token = args[:card_token]
|
|
70
|
+
|
|
71
|
+
Success({ validated_amount: amount, validated_currency: currency })
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
step :fraud_detection do
|
|
76
|
+
argument :validation_data, result(:validate_payment)
|
|
77
|
+
argument :amount, input(:amount)
|
|
78
|
+
argument :card_token, input(:card_token)
|
|
79
|
+
|
|
80
|
+
run do |args, _context|
|
|
81
|
+
amount = args[:amount]
|
|
82
|
+
card_token = args[:card_token]
|
|
83
|
+
|
|
84
|
+
fraud_score = FraudDetectionService.analyze(
|
|
85
|
+
amount: amount,
|
|
86
|
+
card_token: card_token,
|
|
87
|
+
# Additional context...
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if fraud_score > 0.8
|
|
91
|
+
raise "High fraud risk detected (score: #{fraud_score})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Success({ fraud_score: fraud_score, fraud_check_passed: true })
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
step :pre_authorize do
|
|
99
|
+
argument :fraud_data, result(:fraud_detection)
|
|
100
|
+
argument :amount, input(:amount)
|
|
101
|
+
argument :currency, input(:currency)
|
|
102
|
+
argument :card_token, input(:card_token)
|
|
103
|
+
|
|
104
|
+
retries max_attempts: 3, backoff: :exponential, base_delay: 5.seconds
|
|
105
|
+
|
|
106
|
+
run do |args, _context|
|
|
107
|
+
amount = args[:amount]
|
|
108
|
+
currency = args[:currency]
|
|
109
|
+
card_token = args[:card_token]
|
|
110
|
+
|
|
111
|
+
auth_result = PaymentGateway.pre_authorize(
|
|
112
|
+
amount: amount,
|
|
113
|
+
currency: currency,
|
|
114
|
+
card_token: card_token
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
raise "Pre-authorization failed: #{auth_result.error}" unless auth_result.success?
|
|
118
|
+
|
|
119
|
+
Success({ auth_id: auth_result.id, auth_amount: amount })
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
undo do |args, _context|
|
|
123
|
+
auth_id = args[:fraud_data][:auth_id] || args[:auth_id]
|
|
124
|
+
# Void the pre-authorization
|
|
125
|
+
PaymentGateway.void_authorization(auth_id) if auth_id
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
step :charge_payment do
|
|
130
|
+
argument :auth_data, result(:pre_authorize)
|
|
131
|
+
|
|
132
|
+
# Final charge - critical operation
|
|
133
|
+
retries max_attempts: 3, backoff: :fixed, base_delay: 60.seconds
|
|
134
|
+
|
|
135
|
+
run do |args, _context|
|
|
136
|
+
auth_id = args[:auth_data][:auth_id]
|
|
137
|
+
amount = args[:auth_data][:auth_amount]
|
|
138
|
+
currency = args[:currency]
|
|
139
|
+
|
|
140
|
+
charge_result = PaymentGateway.charge(
|
|
141
|
+
auth_id: auth_id,
|
|
142
|
+
amount: amount,
|
|
143
|
+
currency: currency
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
raise "Charge failed: #{charge_result.error}" unless charge_result.success?
|
|
147
|
+
|
|
148
|
+
Success({ charge_id: charge_result.id, charged_amount: amount })
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
undo do |args, _context|
|
|
152
|
+
charge_id = args[:auth_data][:charge_id] || args[:charge_id]
|
|
153
|
+
# Refund the charge
|
|
154
|
+
PaymentGateway.refund(charge_id) if charge_id
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
step :record_transaction do
|
|
159
|
+
argument :charge_data, result(:charge_payment)
|
|
160
|
+
argument :fraud_data, result(:fraud_detection)
|
|
161
|
+
argument :amount, input(:amount)
|
|
162
|
+
argument :currency, input(:currency)
|
|
163
|
+
argument :card_token, input(:card_token)
|
|
164
|
+
|
|
165
|
+
run do |args, _context|
|
|
166
|
+
charge_id = args[:charge_data][:charge_id]
|
|
167
|
+
amount = args[:amount]
|
|
168
|
+
currency = args[:currency]
|
|
169
|
+
card_token = args[:card_token]
|
|
170
|
+
fraud_score = args[:fraud_data][:fraud_score]
|
|
171
|
+
|
|
172
|
+
transaction = PaymentTransaction.create!(
|
|
173
|
+
charge_id: charge_id,
|
|
174
|
+
amount: amount,
|
|
175
|
+
currency: currency,
|
|
176
|
+
card_token: card_token,
|
|
177
|
+
fraud_score: fraud_score,
|
|
178
|
+
status: :completed,
|
|
179
|
+
processed_at: Time.current
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
{ transaction_id: transaction.id }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
compensate do |args, _context|
|
|
186
|
+
transaction_id = args[:charge_data][:transaction_id] || args[:transaction_id]
|
|
187
|
+
# Mark transaction as failed/refunded
|
|
188
|
+
PaymentTransaction.find_by(id: transaction_id)&.update!(status: :refunded)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
step :send_receipt do
|
|
193
|
+
argument :transaction_data, result(:record_transaction)
|
|
194
|
+
argument :amount, input(:amount)
|
|
195
|
+
argument :currency, input(:currency)
|
|
196
|
+
|
|
197
|
+
retries max_attempts: 3, backoff: :linear, base_delay: 10.seconds
|
|
198
|
+
|
|
199
|
+
run do |args, _context|
|
|
200
|
+
transaction_id = args[:transaction_data][:transaction_id]
|
|
201
|
+
amount = args[:amount]
|
|
202
|
+
currency = args[:currency]
|
|
203
|
+
|
|
204
|
+
transaction = PaymentTransaction.find(transaction_id)
|
|
205
|
+
customer = transaction.customer
|
|
206
|
+
|
|
207
|
+
receipt_result = EmailService.send_payment_receipt(
|
|
208
|
+
to: customer.email,
|
|
209
|
+
transaction: transaction,
|
|
210
|
+
amount: amount,
|
|
211
|
+
currency: currency
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
raise "Receipt delivery failed" unless receipt_result.success?
|
|
215
|
+
|
|
216
|
+
{ receipt_sent: true }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Advanced Payment Scenarios
|
|
223
|
+
|
|
224
|
+
### Multi-Attempt Payment Processing
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
class MultiAttemptPaymentReactor < RubyReactor::Reactor
|
|
228
|
+
async true
|
|
229
|
+
|
|
230
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 10.seconds
|
|
231
|
+
|
|
232
|
+
step :attempt_primary_card do
|
|
233
|
+
argument :order, input(:order)
|
|
234
|
+
|
|
235
|
+
run do |args, _context|
|
|
236
|
+
order = args[:order]
|
|
237
|
+
|
|
238
|
+
result = process_payment_with_card(order, order.primary_card)
|
|
239
|
+
if result.success?
|
|
240
|
+
{ payment_result: result, card_used: :primary }
|
|
241
|
+
else
|
|
242
|
+
{ payment_failed: true, primary_card_failed: true }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
step :attempt_backup_card do
|
|
248
|
+
argument :primary_attempt, result(:attempt_primary_card)
|
|
249
|
+
argument :order, input(:order)
|
|
250
|
+
|
|
251
|
+
run do |args, _context|
|
|
252
|
+
order = args[:order]
|
|
253
|
+
primary_card_failed = args[:primary_attempt][:primary_card_failed]
|
|
254
|
+
|
|
255
|
+
return { skipped: true } unless primary_card_failed
|
|
256
|
+
|
|
257
|
+
result = process_payment_with_card(order, order.backup_card)
|
|
258
|
+
if result.success?
|
|
259
|
+
{ payment_result: result, card_used: :backup }
|
|
260
|
+
else
|
|
261
|
+
raise "All payment attempts failed"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
step :process_successful_payment do
|
|
267
|
+
argument :attempt_result, result(:attempt_backup_card)
|
|
268
|
+
|
|
269
|
+
run do |args, _context|
|
|
270
|
+
payment_result = args[:attempt_result][:payment_result]
|
|
271
|
+
card_used = args[:attempt_result][:card_used]
|
|
272
|
+
|
|
273
|
+
# Record successful payment
|
|
274
|
+
PaymentRecord.create!(
|
|
275
|
+
charge_id: payment_result.id,
|
|
276
|
+
card_used: card_used,
|
|
277
|
+
amount: payment_result.amount
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
{ payment_recorded: true }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
def process_payment_with_card(order, card)
|
|
287
|
+
PaymentGateway.charge(
|
|
288
|
+
amount: order.total,
|
|
289
|
+
card_token: card.token,
|
|
290
|
+
description: "Order ##{order.id}"
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Subscription Payment Processing
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
class SubscriptionPaymentReactor < RubyReactor::Reactor
|
|
300
|
+
async true
|
|
301
|
+
|
|
302
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 1.hour
|
|
303
|
+
|
|
304
|
+
input :subscription_id, validate: -> do
|
|
305
|
+
required(:subscription_id).filled(:string)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
step :validate_subscription do
|
|
309
|
+
argument :subscription_id, input(:subscription_id)
|
|
310
|
+
|
|
311
|
+
run do |args, _context|
|
|
312
|
+
subscription_id = args[:subscription_id]
|
|
313
|
+
|
|
314
|
+
subscription = Subscription.find(subscription_id)
|
|
315
|
+
raise "Subscription not found" unless subscription
|
|
316
|
+
raise "Subscription inactive" unless subscription.active?
|
|
317
|
+
|
|
318
|
+
{ subscription: subscription }
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
step :calculate_proration do
|
|
323
|
+
argument :validation_data, result(:validate_subscription)
|
|
324
|
+
|
|
325
|
+
run do |args, _context|
|
|
326
|
+
subscription = args[:validation_data][:subscription]
|
|
327
|
+
|
|
328
|
+
# Calculate prorated amount for billing period
|
|
329
|
+
proration = BillingService.calculate_proration(subscription)
|
|
330
|
+
{ proration_amount: proration.amount, billing_period: proration.period }
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
step :charge_subscription do
|
|
335
|
+
argument :validation_data, result(:validate_subscription)
|
|
336
|
+
argument :proration_data, result(:calculate_proration)
|
|
337
|
+
|
|
338
|
+
run do |args, _context|
|
|
339
|
+
subscription = args[:validation_data][:subscription]
|
|
340
|
+
proration_amount = args[:proration_data][:proration_amount]
|
|
341
|
+
billing_period = args[:proration_data][:billing_period]
|
|
342
|
+
|
|
343
|
+
charge_result = PaymentGateway.charge_subscription(
|
|
344
|
+
customer_id: subscription.customer.stripe_id,
|
|
345
|
+
amount: proration_amount,
|
|
346
|
+
description: "Subscription #{subscription.id} - #{billing_period}"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
raise "Subscription charge failed" unless charge_result.success?
|
|
350
|
+
|
|
351
|
+
{ charge_id: charge_result.id }
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
compensate do |args, _context|
|
|
355
|
+
charge_id = args[:validation_data][:charge_id] || args[:charge_id]
|
|
356
|
+
# Refund the subscription charge
|
|
357
|
+
PaymentGateway.refund_subscription_charge(charge_id) if charge_id
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
step :update_billing_record do
|
|
362
|
+
argument :validation_data, result(:validate_subscription)
|
|
363
|
+
argument :proration_data, result(:calculate_proration)
|
|
364
|
+
argument :charge_data, result(:charge_subscription)
|
|
365
|
+
|
|
366
|
+
run do |args, _context|
|
|
367
|
+
subscription = args[:validation_data][:subscription]
|
|
368
|
+
charge_id = args[:charge_data][:charge_id]
|
|
369
|
+
billing_period = args[:proration_data][:billing_period]
|
|
370
|
+
|
|
371
|
+
BillingRecord.create!(
|
|
372
|
+
subscription: subscription,
|
|
373
|
+
charge_id: charge_id,
|
|
374
|
+
billing_period: billing_period,
|
|
375
|
+
status: :paid
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
{ billing_recorded: true }
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Subscription Payment Processing
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
class SubscriptionPaymentReactor < RubyReactor::Reactor
|
|
387
|
+
async true
|
|
388
|
+
|
|
389
|
+
retry_defaults max_attempts: 3, backoff: :exponential, base_delay: 1.hour
|
|
390
|
+
|
|
391
|
+
input :subscription_id, validate: -> do
|
|
392
|
+
required(:subscription_id).filled(:string)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
step :validate_subscription do
|
|
396
|
+
validate_args do
|
|
397
|
+
required(:subscription_id).filled(:string)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
run do |subscription_id:|
|
|
401
|
+
subscription = Subscription.find(subscription_id)
|
|
402
|
+
raise "Subscription not found" unless subscription
|
|
403
|
+
raise "Subscription inactive" unless subscription.active?
|
|
404
|
+
|
|
405
|
+
{ subscription: subscription }
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
step :calculate_proration do
|
|
410
|
+
argument :validation_data, result(:validate_subscription)
|
|
411
|
+
|
|
412
|
+
run do |args, _context|
|
|
413
|
+
subscription = args[:validation_data][:subscription]
|
|
414
|
+
|
|
415
|
+
# Calculate prorated amount for billing period
|
|
416
|
+
proration = BillingService.calculate_proration(subscription)
|
|
417
|
+
{ proration_amount: proration.amount, billing_period: proration.period }
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
step :charge_subscription do
|
|
422
|
+
argument :validation_data, result(:validate_subscription)
|
|
423
|
+
argument :proration_data, result(:calculate_proration)
|
|
424
|
+
|
|
425
|
+
run do |args, _context|
|
|
426
|
+
subscription = args[:validation_data][:subscription]
|
|
427
|
+
proration_amount = args[:proration_data][:proration_amount]
|
|
428
|
+
billing_period = args[:proration_data][:billing_period]
|
|
429
|
+
|
|
430
|
+
charge_result = PaymentGateway.charge_subscription(
|
|
431
|
+
customer_id: subscription.customer.stripe_id,
|
|
432
|
+
amount: proration_amount,
|
|
433
|
+
description: "Subscription #{subscription.id} - #{billing_period}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
raise "Subscription charge failed" unless charge_result.success?
|
|
437
|
+
|
|
438
|
+
{ charge_id: charge_result.id }
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
compensate do |args, _context|
|
|
442
|
+
charge_id = args[:validation_data][:charge_id] || args[:charge_id]
|
|
443
|
+
# Refund the subscription charge
|
|
444
|
+
PaymentGateway.refund_subscription_charge(charge_id) if charge_id
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
step :update_billing_record do
|
|
449
|
+
argument :validation_data, result(:validate_subscription)
|
|
450
|
+
argument :proration_data, result(:calculate_proration)
|
|
451
|
+
argument :charge_data, result(:charge_subscription)
|
|
452
|
+
|
|
453
|
+
run do |args, _context|
|
|
454
|
+
subscription = args[:validation_data][:subscription]
|
|
455
|
+
charge_id = args[:charge_data][:charge_id]
|
|
456
|
+
billing_period = args[:proration_data][:billing_period]
|
|
457
|
+
|
|
458
|
+
BillingRecord.create!(
|
|
459
|
+
subscription: subscription,
|
|
460
|
+
charge_id: charge_id,
|
|
461
|
+
billing_period: billing_period,
|
|
462
|
+
status: :paid
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
{ billing_recorded: true }
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## Error Handling Patterns
|
|
472
|
+
|
|
473
|
+
### Payment Gateway Timeouts
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
class TimeoutHandlingReactor < RubyReactor::Reactor
|
|
477
|
+
step :handle_timeout do
|
|
478
|
+
retries max_attempts: 5, backoff: :exponential, base_delay: 2.seconds
|
|
479
|
+
|
|
480
|
+
run do
|
|
481
|
+
begin
|
|
482
|
+
Timeout.timeout(10.seconds) do
|
|
483
|
+
PaymentGateway.charge(amount: 100, card_token: token)
|
|
484
|
+
end
|
|
485
|
+
rescue Timeout::Error
|
|
486
|
+
raise PaymentTimeoutError.new("Gateway timeout")
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
class PaymentTimeoutError < StandardError
|
|
493
|
+
def retryable?
|
|
494
|
+
true
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Insufficient Funds Handling
|
|
500
|
+
|
|
501
|
+
```ruby
|
|
502
|
+
class InsufficientFundsReactor < RubyReactor::Reactor
|
|
503
|
+
step :handle_insufficient_funds do
|
|
504
|
+
run do
|
|
505
|
+
result = PaymentGateway.charge(amount: 100, card_token: token)
|
|
506
|
+
|
|
507
|
+
if result.error_code == 'insufficient_funds'
|
|
508
|
+
# Trigger alternative payment flow
|
|
509
|
+
notify_customer_insufficient_funds
|
|
510
|
+
raise NonRetryablePaymentError.new("Insufficient funds")
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
result
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
class NonRetryablePaymentError < StandardError
|
|
519
|
+
def retryable?
|
|
520
|
+
false
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
## Testing Payment Reactors
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
RSpec.describe PaymentProcessingReactor do
|
|
529
|
+
let(:valid_payment_params) do
|
|
530
|
+
{
|
|
531
|
+
amount: 100.00,
|
|
532
|
+
currency: 'USD',
|
|
533
|
+
card_token: 'tok_1234567890'
|
|
534
|
+
}
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
context "successful payment" do
|
|
538
|
+
it "completes all payment steps" do
|
|
539
|
+
allow(FraudDetectionService).to receive(:analyze).and_return(0.3)
|
|
540
|
+
allow(PaymentGateway).to receive(:pre_authorize).and_return(successful_auth)
|
|
541
|
+
allow(PaymentGateway).to receive(:charge).and_return(successful_charge)
|
|
542
|
+
allow(EmailService).to receive(:send_payment_receipt).and_return(successful_email)
|
|
543
|
+
|
|
544
|
+
result = PaymentProcessingReactor.run(valid_payment_params)
|
|
545
|
+
|
|
546
|
+
expect(result).to be_success
|
|
547
|
+
expect(result.step_results[:charge_payment][:charge_id]).to be_present
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
context "fraud detection" do
|
|
552
|
+
it "blocks high-risk payments" do
|
|
553
|
+
allow(FraudDetectionService).to receive(:analyze).and_return(0.9)
|
|
554
|
+
|
|
555
|
+
result = PaymentProcessingReactor.run(valid_payment_params)
|
|
556
|
+
|
|
557
|
+
expect(result).to be_failure
|
|
558
|
+
expect(result.error.message).to include("High fraud risk")
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
context "payment gateway failure" do
|
|
563
|
+
it "retries pre-authorization" do
|
|
564
|
+
allow(FraudDetectionService).to receive(:analyze).and_return(0.3)
|
|
565
|
+
allow(PaymentGateway).to receive(:pre_authorize)
|
|
566
|
+
.and_raise(PaymentGateway::TimeoutError)
|
|
567
|
+
.and_raise(PaymentGateway::TimeoutError)
|
|
568
|
+
.and_return(successful_auth)
|
|
569
|
+
|
|
570
|
+
expect(PaymentGateway).to receive(:pre_authorize).exactly(3).times
|
|
571
|
+
|
|
572
|
+
result = PaymentProcessingReactor.run(valid_payment_params)
|
|
573
|
+
|
|
574
|
+
expect(result).to be_success
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
context "compensation" do
|
|
579
|
+
it "refunds on final charge failure" do
|
|
580
|
+
allow(FraudDetectionService).to receive(:analyze).and_return(0.3)
|
|
581
|
+
allow(PaymentGateway).to receive(:pre_authorize).and_return(successful_auth)
|
|
582
|
+
allow(PaymentGateway).to receive(:charge).and_raise(PaymentGateway::DeclineError)
|
|
583
|
+
|
|
584
|
+
expect(PaymentGateway).to receive(:void_authorization)
|
|
585
|
+
|
|
586
|
+
result = PaymentProcessingReactor.run(valid_payment_params)
|
|
587
|
+
|
|
588
|
+
expect(result).to be_failure
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
## Monitoring and Alerting
|
|
595
|
+
|
|
596
|
+
```ruby
|
|
597
|
+
# Key metrics for payment processing
|
|
598
|
+
PAYMENT_METRICS = {
|
|
599
|
+
payment_success_rate: "Percentage of successful payments",
|
|
600
|
+
fraud_detection_rate: "Percentage of payments flagged as fraud",
|
|
601
|
+
average_payment_time: "Average time to process payment",
|
|
602
|
+
retry_rate: "Percentage of payments requiring retries",
|
|
603
|
+
chargeback_rate: "Rate of payment chargebacks"
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
# Alerts
|
|
607
|
+
PAYMENT_ALERTS = {
|
|
608
|
+
high_failure_rate: "Payment success rate below 95%",
|
|
609
|
+
high_fraud_rate: "Fraud detection rate above 10%",
|
|
610
|
+
slow_processing: "Average payment time above 30 seconds"
|
|
611
|
+
}
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
## Security Considerations
|
|
615
|
+
|
|
616
|
+
1. **PCI Compliance**: Never log full card details
|
|
617
|
+
2. **Tokenization**: Use payment provider tokens, not raw card data
|
|
618
|
+
3. **Idempotency Keys**: Prevent duplicate charges
|
|
619
|
+
4. **Rate Limiting**: Implement per-customer rate limits
|
|
620
|
+
5. **Audit Logging**: Log all payment attempts (without sensitive data)
|
|
621
|
+
|
|
622
|
+
## Performance Optimization
|
|
623
|
+
|
|
624
|
+
### Connection Pooling
|
|
625
|
+
|
|
626
|
+
```ruby
|
|
627
|
+
# Configure payment gateway connection pooling
|
|
628
|
+
PaymentGateway.configure do |config|
|
|
629
|
+
config.connection_pool_size = 10
|
|
630
|
+
config.connection_timeout = 5.seconds
|
|
631
|
+
end
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
### Caching
|
|
635
|
+
|
|
636
|
+
```ruby
|
|
637
|
+
# Cache fraud detection models
|
|
638
|
+
Rails.cache.fetch("fraud_model_#{Date.today}", expires_in: 1.day) do
|
|
639
|
+
FraudDetectionService.load_model
|
|
640
|
+
end
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### Async Processing
|
|
644
|
+
|
|
645
|
+
```ruby
|
|
646
|
+
# Use async for non-critical steps
|
|
647
|
+
step :send_receipt do
|
|
648
|
+
async true # Don't block payment completion on email delivery
|
|
649
|
+
|
|
650
|
+
run do
|
|
651
|
+
# Email sending logic
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
```
|