zai_payment 1.0.2 → 1.2.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.
@@ -0,0 +1,635 @@
1
+ # Webhook Examples
2
+
3
+ This file demonstrates how to use the ZaiPayment webhook functionality.
4
+
5
+ ## Setup
6
+
7
+ ```ruby
8
+ require 'zai_payment'
9
+
10
+ # Configure the gem
11
+ ZaiPayment.configure do |config|
12
+ config.environment = :prelive # or :production
13
+ config.client_id = 'your_client_id'
14
+ config.client_secret = 'your_client_secret'
15
+ config.scope = 'your_scope'
16
+ end
17
+ ```
18
+
19
+ ## Webhook Security: Complete Setup Guide
20
+
21
+ ### Step 1: Generate and Register a Secret Key
22
+
23
+ Before setting up webhooks, you should establish a secure secret key for signature verification:
24
+
25
+ ```ruby
26
+ require 'securerandom'
27
+
28
+ # Generate a cryptographically secure secret key (minimum 32 bytes)
29
+ secret_key = SecureRandom.alphanumeric(32)
30
+ # Example output: "aB3xYz9mKpQrTuVwXy2zAbCdEfGhIjKl"
31
+
32
+ # Store this in your environment variables or secure vault
33
+ # NEVER commit this to version control!
34
+ puts "Add this to your environment variables:"
35
+ puts "ZAI_WEBHOOK_SECRET=#{secret_key}"
36
+
37
+ # Register the secret key with Zai
38
+ response = ZaiPayment.webhooks.create_secret_key(secret_key: secret_key)
39
+
40
+ if response.success?
41
+ puts "✅ Secret key registered successfully with Zai!"
42
+ puts "Store this key securely - you'll need it to verify webhook signatures"
43
+ else
44
+ puts "❌ Failed to register secret key"
45
+ end
46
+ ```
47
+
48
+ ### Step 2: Create a Webhook
49
+
50
+ Now create a webhook to receive notifications:
51
+
52
+ ```ruby
53
+ response = ZaiPayment.webhooks.create(
54
+ url: 'https://your-app.com/webhooks/zai',
55
+ object_type: 'transactions',
56
+ enabled: true,
57
+ description: 'Production webhook for transaction updates'
58
+ )
59
+
60
+ webhook = response.data
61
+ puts "Created webhook: #{webhook['id']}"
62
+ ```
63
+
64
+ ### Step 3: Implement Webhook Endpoint
65
+
66
+ Here's a complete Rails controller example with signature verification:
67
+
68
+ ```ruby
69
+ # app/controllers/webhooks_controller.rb
70
+ class WebhooksController < ApplicationController
71
+ # Skip CSRF token verification for webhook endpoints
72
+ skip_before_action :verify_authenticity_token
73
+
74
+ # Add basic rate limiting (if using Rack::Attack or similar)
75
+ # throttle('webhooks/ip', limit: 100, period: 1.minute)
76
+
77
+ def zai_webhook
78
+ # Read the raw request body - IMPORTANT: Don't parse it first!
79
+ payload = request.body.read
80
+ signature_header = request.headers['Webhooks-signature']
81
+ secret_key = ENV['ZAI_WEBHOOK_SECRET']
82
+
83
+ # Verify the signature
84
+ unless verify_webhook_signature(payload, signature_header, secret_key)
85
+ Rails.logger.warn "Invalid webhook signature received from #{request.remote_ip}"
86
+ return render json: { error: 'Invalid signature' }, status: :unauthorized
87
+ end
88
+
89
+ # Parse and process the webhook
90
+ webhook_data = JSON.parse(payload)
91
+ process_webhook(webhook_data)
92
+
93
+ # Return 200 to acknowledge receipt
94
+ render json: { status: 'success' }, status: :ok
95
+ rescue JSON::ParserError => e
96
+ Rails.logger.error "Invalid JSON in webhook: #{e.message}"
97
+ render json: { error: 'Invalid JSON' }, status: :bad_request
98
+ rescue StandardError => e
99
+ Rails.logger.error "Webhook processing error: #{e.message}"
100
+ render json: { error: 'Processing error' }, status: :internal_server_error
101
+ end
102
+
103
+ private
104
+
105
+ def verify_webhook_signature(payload, signature_header, secret_key)
106
+ return false if signature_header.blank?
107
+
108
+ ZaiPayment.webhooks.verify_signature(
109
+ payload: payload,
110
+ signature_header: signature_header,
111
+ secret_key: secret_key,
112
+ tolerance: 300 # 5 minutes - adjust based on your needs
113
+ )
114
+ rescue ZaiPayment::Errors::ValidationError => e
115
+ Rails.logger.warn "Webhook signature validation failed: #{e.message}"
116
+ false
117
+ end
118
+
119
+ def process_webhook(data)
120
+ # Log the webhook for debugging
121
+ Rails.logger.info "Processing Zai webhook: #{data['event']}"
122
+
123
+ # Handle different webhook events
124
+ case data['event']
125
+ when 'transaction.created'
126
+ handle_transaction_created(data)
127
+ when 'transaction.updated'
128
+ handle_transaction_updated(data)
129
+ when 'transaction.completed'
130
+ handle_transaction_completed(data)
131
+ else
132
+ Rails.logger.info "Unhandled webhook event: #{data['event']}"
133
+ end
134
+ end
135
+
136
+ def handle_transaction_created(data)
137
+ # Your logic here
138
+ Rails.logger.info "Transaction created: #{data['transaction']['id']}"
139
+ end
140
+
141
+ def handle_transaction_updated(data)
142
+ # Your logic here
143
+ Rails.logger.info "Transaction updated: #{data['transaction']['id']}"
144
+ end
145
+
146
+ def handle_transaction_completed(data)
147
+ # Your logic here
148
+ Rails.logger.info "Transaction completed: #{data['transaction']['id']}"
149
+ end
150
+ end
151
+ ```
152
+
153
+ ### Step 4: Configure Routes
154
+
155
+ ```ruby
156
+ # config/routes.rb
157
+ Rails.application.routes.draw do
158
+ # Webhook endpoint
159
+ post '/webhooks/zai', to: 'webhooks#zai_webhook'
160
+ end
161
+ ```
162
+
163
+ ### Step 5: Test Your Webhook
164
+
165
+ Create a test to verify your implementation:
166
+
167
+ ```ruby
168
+ # spec/controllers/webhooks_controller_spec.rb
169
+ require 'rails_helper'
170
+
171
+ RSpec.describe WebhooksController, type: :controller do
172
+ let(:secret_key) { SecureRandom.alphanumeric(32) }
173
+ let(:webhook_payload) do
174
+ {
175
+ event: 'transaction.updated',
176
+ transaction: {
177
+ id: 'txn_123',
178
+ state: 'completed',
179
+ amount: 1000
180
+ }
181
+ }.to_json
182
+ end
183
+
184
+ before do
185
+ ENV['ZAI_WEBHOOK_SECRET'] = secret_key
186
+ end
187
+
188
+ describe 'POST #zai_webhook' do
189
+ context 'with valid signature' do
190
+ it 'processes the webhook successfully' do
191
+ timestamp = Time.now.to_i
192
+ signature = ZaiPayment::Resources::Webhook.new.generate_signature(
193
+ webhook_payload, secret_key, timestamp
194
+ )
195
+
196
+ request.headers['Webhooks-signature'] = "t=#{timestamp},v=#{signature}"
197
+ post :zai_webhook, body: webhook_payload
198
+
199
+ expect(response).to have_http_status(:ok)
200
+ expect(JSON.parse(response.body)['status']).to eq('success')
201
+ end
202
+ end
203
+
204
+ context 'with invalid signature' do
205
+ it 'rejects the webhook' do
206
+ timestamp = Time.now.to_i
207
+ request.headers['Webhooks-signature'] = "t=#{timestamp},v=invalid_signature"
208
+ post :zai_webhook, body: webhook_payload
209
+
210
+ expect(response).to have_http_status(:unauthorized)
211
+ end
212
+ end
213
+
214
+ context 'with missing signature header' do
215
+ it 'rejects the webhook' do
216
+ post :zai_webhook, body: webhook_payload
217
+
218
+ expect(response).to have_http_status(:unauthorized)
219
+ end
220
+ end
221
+
222
+ context 'with expired timestamp' do
223
+ it 'rejects old webhooks to prevent replay attacks' do
224
+ old_timestamp = Time.now.to_i - 600 # 10 minutes ago
225
+ signature = ZaiPayment::Resources::Webhook.new.generate_signature(
226
+ webhook_payload, secret_key, old_timestamp
227
+ )
228
+
229
+ request.headers['Webhooks-signature'] = "t=#{old_timestamp},v=#{signature}"
230
+ post :zai_webhook, body: webhook_payload
231
+
232
+ expect(response).to have_http_status(:unauthorized)
233
+ end
234
+ end
235
+ end
236
+ end
237
+ ```
238
+
239
+ ## Basic Webhook Operations
240
+
241
+ ## List Webhooks
242
+
243
+ ```ruby
244
+ # Get all webhooks
245
+ response = ZaiPayment.webhooks.list
246
+ puts response.data # Array of webhooks
247
+ puts response.meta # Pagination metadata
248
+
249
+ # With pagination
250
+ response = ZaiPayment.webhooks.list(limit: 20, offset: 10)
251
+ ```
252
+
253
+ ## Show a Specific Webhook
254
+
255
+ ```ruby
256
+ webhook_id = 'webhook_123'
257
+ response = ZaiPayment.webhooks.show(webhook_id)
258
+
259
+ webhook = response.data
260
+ puts webhook['id']
261
+ puts webhook['url']
262
+ puts webhook['object_type']
263
+ puts webhook['enabled']
264
+ ```
265
+
266
+ ## Create a Webhook
267
+
268
+ ```ruby
269
+ response = ZaiPayment.webhooks.create(
270
+ url: 'https://example.com/webhooks/zai',
271
+ object_type: 'transactions',
272
+ enabled: true,
273
+ description: 'Production webhook for transactions'
274
+ )
275
+
276
+ new_webhook = response.data
277
+ puts "Created webhook with ID: #{new_webhook['id']}"
278
+ ```
279
+
280
+ ## Update a Webhook
281
+
282
+ ```ruby
283
+ webhook_id = 'webhook_123'
284
+
285
+ # Update specific fields
286
+ response = ZaiPayment.webhooks.update(
287
+ webhook_id,
288
+ enabled: false,
289
+ description: 'Temporarily disabled'
290
+ )
291
+
292
+ # Or update multiple fields
293
+ response = ZaiPayment.webhooks.update(
294
+ webhook_id,
295
+ url: 'https://example.com/webhooks/zai-v2',
296
+ object_type: 'items',
297
+ enabled: true
298
+ )
299
+ ```
300
+
301
+ ## Delete a Webhook
302
+
303
+ ```ruby
304
+ webhook_id = 'webhook_123'
305
+ response = ZaiPayment.webhooks.delete(webhook_id)
306
+
307
+ if response.success?
308
+ puts "Webhook deleted successfully"
309
+ end
310
+ ```
311
+
312
+ ## Error Handling
313
+
314
+ ```ruby
315
+ begin
316
+ response = ZaiPayment.webhooks.create(
317
+ url: 'https://example.com/webhook',
318
+ object_type: 'transactions'
319
+ )
320
+ rescue ZaiPayment::Errors::ValidationError => e
321
+ puts "Validation error: #{e.message}"
322
+ rescue ZaiPayment::Errors::UnauthorizedError => e
323
+ puts "Authentication failed: #{e.message}"
324
+ rescue ZaiPayment::Errors::NotFoundError => e
325
+ puts "Resource not found: #{e.message}"
326
+ rescue ZaiPayment::Errors::ApiError => e
327
+ puts "API error: #{e.message}"
328
+ end
329
+ ```
330
+
331
+ ## Using Custom Client Instance
332
+
333
+ If you need more control, you can create your own client instance:
334
+
335
+ ```ruby
336
+ config = ZaiPayment::Config.new
337
+ config.environment = :prelive
338
+ config.client_id = 'your_client_id'
339
+ config.client_secret = 'your_client_secret'
340
+ config.scope = 'your_scope'
341
+
342
+ token_provider = ZaiPayment::Auth::TokenProvider.new(config: config)
343
+ client = ZaiPayment::Client.new(config: config, token_provider: token_provider)
344
+
345
+ webhooks = ZaiPayment::Resources::Webhook.new(client: client)
346
+ response = webhooks.list
347
+ ```
348
+
349
+ ## Response Object
350
+
351
+ All webhook methods return a `ZaiPayment::Response` object with the following methods:
352
+
353
+ ```ruby
354
+ response = ZaiPayment.webhooks.list
355
+
356
+ # Check status
357
+ response.success? # => true/false (2xx status)
358
+ response.client_error? # => true/false (4xx status)
359
+ response.server_error? # => true/false (5xx status)
360
+
361
+ # Access data
362
+ response.data # => Main response data (array or hash)
363
+ response.meta # => Pagination metadata (if available)
364
+ response.body # => Raw response body
365
+ response.headers # => Response headers
366
+ response.status # => HTTP status code
367
+ ```
368
+
369
+ ## Additional Webhook Security Examples
370
+
371
+ ### Generate Signature for Testing
372
+
373
+ You can generate signatures for testing your webhook implementation:
374
+
375
+ ```ruby
376
+ # Useful for integration tests or webhook simulation
377
+ payload = '{"event": "transaction.updated", "id": "txn_123"}'
378
+ secret_key = ENV['ZAI_WEBHOOK_SECRET']
379
+ timestamp = Time.now.to_i
380
+
381
+ webhook = ZaiPayment::Resources::Webhook.new
382
+ signature = webhook.generate_signature(payload, secret_key, timestamp)
383
+
384
+ puts "Signature header: t=#{timestamp},v=#{signature}"
385
+ ```
386
+
387
+ ### Verify Signature Manually
388
+
389
+ If you need more control over the verification process:
390
+
391
+ ```ruby
392
+ webhook = ZaiPayment::Resources::Webhook.new
393
+
394
+ begin
395
+ is_valid = webhook.verify_signature(
396
+ payload: request_body,
397
+ signature_header: request.headers['Webhooks-signature'],
398
+ secret_key: ENV['ZAI_WEBHOOK_SECRET'],
399
+ tolerance: 300
400
+ )
401
+
402
+ if is_valid
403
+ puts "✅ Webhook signature is valid"
404
+ else
405
+ puts "❌ Webhook signature is invalid"
406
+ end
407
+ rescue ZaiPayment::Errors::ValidationError => e
408
+ puts "⚠️ Validation error: #{e.message}"
409
+ end
410
+ ```
411
+
412
+ ### Sinatra Example
413
+
414
+ If you're using Sinatra instead of Rails:
415
+
416
+ ```ruby
417
+ require 'sinatra'
418
+ require 'json'
419
+ require 'zai_payment'
420
+
421
+ # Configure ZaiPayment
422
+ ZaiPayment.configure do |config|
423
+ config.environment = :prelive
424
+ config.client_id = ENV['ZAI_CLIENT_ID']
425
+ config.client_secret = ENV['ZAI_CLIENT_SECRET']
426
+ config.scope = ENV['ZAI_SCOPE']
427
+ end
428
+
429
+ post '/webhooks/zai' do
430
+ # Read raw request body
431
+ payload = request.body.read
432
+ signature_header = request.env['HTTP_WEBHOOKS_SIGNATURE']
433
+ secret_key = ENV['ZAI_WEBHOOK_SECRET']
434
+
435
+ # Verify signature
436
+ webhook = ZaiPayment::Resources::Webhook.new
437
+
438
+ begin
439
+ unless webhook.verify_signature(
440
+ payload: payload,
441
+ signature_header: signature_header,
442
+ secret_key: secret_key
443
+ )
444
+ halt 401, { error: 'Invalid signature' }.to_json
445
+ end
446
+
447
+ # Process webhook
448
+ data = JSON.parse(payload)
449
+ logger.info "Received webhook: #{data['event']}"
450
+
451
+ # Your processing logic here
452
+
453
+ status 200
454
+ { status: 'success' }.to_json
455
+ rescue ZaiPayment::Errors::ValidationError => e
456
+ logger.error "Webhook validation failed: #{e.message}"
457
+ halt 401, { error: e.message }.to_json
458
+ rescue StandardError => e
459
+ logger.error "Webhook processing error: #{e.message}"
460
+ halt 500, { error: 'Processing error' }.to_json
461
+ end
462
+ end
463
+ ```
464
+
465
+ ### Rack Middleware Example
466
+
467
+ Create reusable middleware for webhook verification:
468
+
469
+ ```ruby
470
+ # lib/middleware/zai_webhook_verifier.rb
471
+ module Middleware
472
+ class ZaiWebhookVerifier
473
+ def initialize(app, options = {})
474
+ @app = app
475
+ @secret_key = options[:secret_key] || ENV['ZAI_WEBHOOK_SECRET']
476
+ @tolerance = options[:tolerance] || 300
477
+ @webhook_path = options[:path] || '/webhooks/zai'
478
+ end
479
+
480
+ def call(env)
481
+ request = Rack::Request.new(env)
482
+
483
+ # Only verify requests to the webhook path
484
+ if request.path == @webhook_path && request.post?
485
+ unless verify_request(request)
486
+ return [401, { 'Content-Type' => 'application/json' },
487
+ [{ error: 'Invalid webhook signature' }.to_json]]
488
+ end
489
+ end
490
+
491
+ @app.call(env)
492
+ end
493
+
494
+ private
495
+
496
+ def verify_request(request)
497
+ body = request.body.read
498
+ request.body.rewind # Important: rewind for downstream processing
499
+
500
+ signature_header = request.env['HTTP_WEBHOOKS_SIGNATURE']
501
+ return false unless signature_header
502
+
503
+ webhook = ZaiPayment::Resources::Webhook.new
504
+ webhook.verify_signature(
505
+ payload: body,
506
+ signature_header: signature_header,
507
+ secret_key: @secret_key,
508
+ tolerance: @tolerance
509
+ )
510
+ rescue ZaiPayment::Errors::ValidationError
511
+ false
512
+ end
513
+ end
514
+ end
515
+
516
+ # Usage in config.ru or Rails application.rb:
517
+ use Middleware::ZaiWebhookVerifier,
518
+ secret_key: ENV['ZAI_WEBHOOK_SECRET'],
519
+ tolerance: 300,
520
+ path: '/webhooks/zai'
521
+ ```
522
+
523
+ ### Background Job Processing
524
+
525
+ For production systems, process webhooks asynchronously:
526
+
527
+ ```ruby
528
+ # app/controllers/webhooks_controller.rb
529
+ class WebhooksController < ApplicationController
530
+ skip_before_action :verify_authenticity_token
531
+
532
+ def zai_webhook
533
+ payload = request.body.read
534
+ signature_header = request.headers['Webhooks-signature']
535
+
536
+ # Quick verification
537
+ unless verify_signature(payload, signature_header)
538
+ return render json: { error: 'Invalid signature' }, status: :unauthorized
539
+ end
540
+
541
+ # Enqueue for background processing
542
+ ZaiWebhookJob.perform_later(payload)
543
+
544
+ # Return immediately
545
+ render json: { status: 'accepted' }, status: :accepted
546
+ end
547
+
548
+ private
549
+
550
+ def verify_signature(payload, signature_header)
551
+ ZaiPayment.webhooks.verify_signature(
552
+ payload: payload,
553
+ signature_header: signature_header,
554
+ secret_key: ENV['ZAI_WEBHOOK_SECRET']
555
+ )
556
+ rescue ZaiPayment::Errors::ValidationError
557
+ false
558
+ end
559
+ end
560
+
561
+ # app/jobs/zai_webhook_job.rb
562
+ class ZaiWebhookJob < ApplicationJob
563
+ queue_as :webhooks
564
+
565
+ # Retry logic for transient failures
566
+ retry_on StandardError, wait: :exponentially_longer, attempts: 5
567
+
568
+ def perform(payload)
569
+ data = JSON.parse(payload)
570
+
571
+ # Idempotent processing - check if already processed
572
+ return if WebhookEvent.exists?(external_id: data['id'])
573
+
574
+ # Process the webhook
575
+ WebhookEvent.create!(
576
+ external_id: data['id'],
577
+ event_type: data['event'],
578
+ payload: data,
579
+ processed_at: Time.current
580
+ )
581
+
582
+ # Handle event
583
+ case data['event']
584
+ when 'transaction.completed'
585
+ TransactionProcessor.process_completion(data['transaction'])
586
+ when 'transaction.failed'
587
+ TransactionProcessor.process_failure(data['transaction'])
588
+ end
589
+ end
590
+ end
591
+ ```
592
+
593
+ ### Idempotency Pattern
594
+
595
+ Ensure webhooks are processed only once:
596
+
597
+ ```ruby
598
+ # app/models/webhook_event.rb
599
+ class WebhookEvent < ApplicationRecord
600
+ # Columns: external_id, event_type, payload (jsonb), processed_at, created_at
601
+
602
+ validates :external_id, presence: true, uniqueness: true
603
+
604
+ def self.process_if_new(webhook_data)
605
+ # Use database constraint to ensure atomicity
606
+ transaction do
607
+ event = create!(
608
+ external_id: webhook_data['id'],
609
+ event_type: webhook_data['event'],
610
+ payload: webhook_data
611
+ )
612
+
613
+ yield event if block_given?
614
+
615
+ event.update!(processed_at: Time.current)
616
+ end
617
+ rescue ActiveRecord::RecordNotUnique
618
+ Rails.logger.info "Webhook already processed: #{webhook_data['id']}"
619
+ false
620
+ end
621
+ end
622
+
623
+ # Usage:
624
+ def process_webhook(data)
625
+ WebhookEvent.process_if_new(data) do |event|
626
+ # Your processing logic here
627
+ # This block only runs if the webhook is new
628
+ case event.event_type
629
+ when 'transaction.completed'
630
+ handle_completion(data)
631
+ end
632
+ end
633
+ end
634
+ ```
635
+