payu_pl 0.2.0 → 0.3.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,383 @@
1
+ # PayU Webhook Integration Guide
2
+
3
+ This guide shows how to integrate PayU webhook validation into your application.
4
+
5
+ ## Overview
6
+
7
+ PayU sends webhook notifications when order status changes. Each webhook includes:
8
+ - Order details (ID, status, amount, etc.)
9
+ - A signature header for verification
10
+ - JSON payload with the order data
11
+
12
+ The `PayuPl::Webhooks::Validator` class handles signature verification and payload parsing.
13
+
14
+ ## Configuration
15
+
16
+ ### Option 1: Via Initializer (Recommended for Rails)
17
+
18
+ ```ruby
19
+ # config/initializers/payu.rb
20
+ PayuPl.configure do |config|
21
+ config.second_key = ENV.fetch('PAYU_SECOND_KEY')
22
+ end
23
+ ```
24
+
25
+ ### Option 2: Via Environment Variable
26
+
27
+ Set `PAYU_SECOND_KEY` in your environment:
28
+
29
+ ```bash
30
+ export PAYU_SECOND_KEY=your_second_key_here
31
+ ```
32
+
33
+ The validator will automatically use this if no other configuration is provided.
34
+
35
+ ### Option 3: Pass Directly
36
+
37
+ ```ruby
38
+ validator = PayuPl::Webhooks::Validator.new(request, second_key: 'custom_key')
39
+ ```
40
+
41
+ ## Finding Your Second Key
42
+
43
+ 1. Log in to PayU merchant panel
44
+ 2. Go to Settings > POS Configuration
45
+ 3. Find "Second Key" (also called "MD5 Key")
46
+ 4. This is the secret used for webhook signature verification
47
+
48
+ **Important**: Keep this key secure! Never commit it to version control.
49
+
50
+ ## Basic Usage
51
+
52
+ ```ruby
53
+ # Create validator with request object
54
+ validator = PayuPl::Webhooks::Validator.new(request)
55
+
56
+ # Validate and parse in one step
57
+ result = validator.validate_and_parse
58
+
59
+ if result.success?
60
+ payload = result.data
61
+ order_id = payload.dig('order', 'orderId')
62
+ status = payload.dig('order', 'status')
63
+
64
+ # Convert amount from minor units to major units
65
+ amount_minor = payload.dig('order', 'totalAmount').to_i # e.g., 2900
66
+ amount_major = amount_minor / 100.0 # e.g., 29.00
67
+ currency = payload.dig('order', 'currencyCode') # e.g., "PLN"
68
+
69
+ logger.info("Order #{order_id}: #{amount_major} #{currency}")
70
+ # Process webhook...
71
+ else
72
+ # Handle validation error
73
+ logger.error("Webhook validation failed: #{result.error}")
74
+ end
75
+ ```
76
+
77
+ ## Framework-Specific Examples
78
+
79
+ ### Rails
80
+
81
+ See [webhooks_controller.rb](webhooks_controller.rb) for a complete Rails example.
82
+
83
+ Key points:
84
+ - Skip CSRF token verification
85
+ - Use logger for debugging
86
+ - Handle duplicates
87
+ - Process asynchronously
88
+
89
+ ### Sinatra
90
+
91
+ See [sinatra_webhook_example.rb](sinatra_webhook_example.rb) for a complete Sinatra example.
92
+
93
+ Key points:
94
+ - Simple route definition
95
+ - Direct request handling
96
+ - Minimal boilerplate
97
+
98
+ ### Rack
99
+
100
+ See [rack_webhook_example.ru](rack_webhook_example.ru) for a complete Rack example.
101
+
102
+ Key points:
103
+ - Works with any Rack-compatible framework
104
+ - No framework dependencies
105
+ - Easy to integrate
106
+
107
+ ## Webhook Payload Structure
108
+
109
+ PayU sends notifications in JSON format using POST method.
110
+
111
+ Example payload from PayU:
112
+
113
+ ```json
114
+ {
115
+ "order": {
116
+ "orderId": "WZHF5FFDRJ140731GUEST000P01",
117
+ "extOrderId": "your-order-id-123",
118
+ "orderCreateDate": "2024-01-15T10:30:00.000Z",
119
+ "notifyUrl": "https://your-app.com/webhooks/payu",
120
+ "customerIp": "127.0.0.1",
121
+ "merchantPosId": "300000",
122
+ "description": "Order description",
123
+ "currencyCode": "PLN",
124
+ "totalAmount": "21000",
125
+ "status": "COMPLETED",
126
+ "products": [
127
+ {
128
+ "name": "Product Name",
129
+ "unitPrice": "21000",
130
+ "quantity": "1"
131
+ }
132
+ ]
133
+ },
134
+ "localReceiptDateTime": "2024-01-15T10:35:00.000Z",
135
+ "properties": [
136
+ {
137
+ "name": "PAYMENT_ID",
138
+ "value": "123456789"
139
+ }
140
+ ]
141
+ }
142
+ ```
143
+
144
+ **Important Payload Notes**:
145
+
146
+ - `totalAmount` / `unitPrice` - **Always in minor units** (e.g., 2900 = 29.00 PLN, 1050 = 10.50 EUR)
147
+ - Divide by 100 to convert to major currency units
148
+ - Example: `"totalAmount": "2900"` means 29.00 PLN
149
+ - `localReceiptDateTime` - Only present for **COMPLETED** status
150
+ - `properties[PAYMENT_ID]` - Payment identifier shown on transaction statements (Trans ID in management panel)
151
+ - `payMethod.type` - Payment method used (if present):
152
+ - `PBL` - Online/standard transfer
153
+ - `CARD_TOKEN` - Card payment
154
+ - `INSTALLMENTS` - PayU Installments
155
+
156
+ ## Order Status Values
157
+
158
+ PayU sends notifications for orders in these statuses:
159
+
160
+ - `PENDING` - Payment is currently being processed
161
+ - `WAITING_FOR_CONFIRMATION` - PayU is waiting for merchant to capture payment (when auto-receive is disabled)
162
+ - `COMPLETED` - Payment accepted, funds will be paid out shortly
163
+ - `CANCELED` - Payment cancelled, buyer was not charged
164
+
165
+ ## PayU Notification Retry Mechanism
166
+
167
+ PayU expects a **200 HTTP status code** response. If a different status is received, PayU will retry sending the notification up to **20 times** with increasing intervals:
168
+
169
+ | Attempt | Retry After |
170
+ |---------|-------------|
171
+ | 1 | Immediately |
172
+ | 2 | 1 minute |
173
+ | 3 | 2 minutes |
174
+ | 4 | 5 minutes |
175
+ | 5 | 10 minutes |
176
+ | 6 | 30 minutes |
177
+ | 7 | 1 hour |
178
+ | 8-20 | 2-72 hours |
179
+
180
+ **Important**: Always return 200 OK to acknowledge receipt, even if processing fails internally. Process errors should be handled asynchronously.
181
+
182
+ ## IP Address Whitelisting
183
+
184
+ If you filter incoming requests by IP, allow these PayU addresses:
185
+
186
+ ### Production
187
+ ```
188
+ 185.68.12.10, 185.68.12.11, 185.68.12.12
189
+ 185.68.12.26, 185.68.12.27, 185.68.12.28
190
+ ```
191
+
192
+ ### Sandbox
193
+ ```
194
+ 185.68.14.10, 185.68.14.11, 185.68.14.12
195
+ 185.68.14.26, 185.68.14.27, 185.68.14.28
196
+ ```
197
+
198
+ ## Notification Headers
199
+
200
+ PayU sends these headers with each notification:
201
+
202
+ - `OpenPayu-Signature` - Signature for verification (also sent as `X-OpenPayU-Signature`)
203
+ - `Content-Type` - Always `application/json;charset=UTF-8`
204
+ - `Authorization` - Basic auth credentials
205
+ - `PayU-Processing-Time` - Time (ms) spent processing at PayU side (selected statuses only)
206
+
207
+ Example signature header:
208
+ ```
209
+ OpenPayu-Signature: sender=checkout;signature=d47d8a771d558c29285887febddd9327;algorithm=MD5;content=DOCUMENT
210
+ ```
211
+
212
+ ## Best Practices
213
+
214
+ ### 1. Handle Duplicates
215
+
216
+ PayU may send the same webhook multiple times due to:
217
+ - Retry mechanism (if you don't return 200)
218
+ - Network issues
219
+ - PayU's intentional duplicate prevention
220
+
221
+ Always check for duplicates and return 200 even for duplicates:
222
+
223
+ ```ruby
224
+ event_id = payload['eventId'] || "#{order_id}_#{status}"
225
+
226
+ if WebhookEvent.exists?(event_id: event_id)
227
+ logger.warn("Duplicate webhook: #{event_id}")
228
+ return head :ok # Return 200 to acknowledge
229
+ end
230
+
231
+ # Store event...
232
+ ```
233
+
234
+ ### 2. Process Asynchronously
235
+
236
+ Don't block the webhook endpoint. Respond quickly and process in background:
237
+
238
+ ```ruby
239
+ # Store webhook
240
+ WebhookEvent.create!(event_id: event_id, payload: payload)
241
+
242
+ # Process asynchronously
243
+ PayuWebhookJob.perform_async(event_id)
244
+
245
+ # Respond immediately
246
+ head :ok
247
+ ```
248
+
249
+ ### 3. Return Correct Status Codes
250
+
251
+ **Critical**: Always return `200 OK` after validating the signature, even if your business logic fails.
252
+
253
+ ```ruby
254
+ # ✅ GOOD - Return 200 after validation
255
+ result = validator.validate_and_parse
256
+ if result.failure?
257
+ return head :bad_request # Invalid signature
258
+ end
259
+
260
+ # Store and process asynchronously
261
+ WebhookEvent.create!(payload: result.data)
262
+ head :ok # Return 200 immediately
263
+
264
+ # ❌ BAD - Don't return 500 for business logic errors
265
+ # This will cause PayU to retry unnecessarily
266
+ ```
267
+
268
+ Status code meanings:
269
+ - `200 OK` - Webhook received and validated (return this even if your processing fails)
270
+ - `400 Bad Request` - Invalid signature/payload only
271
+ - `500 Internal Server Error` - Should be avoided; causes PayU to retry
272
+
273
+ **Note**: If you return non-200, PayU will retry up to 20 times over 72 hours.
274
+
275
+ ### 4. Enable Logging
276
+
277
+ Use logging to debug issues:
278
+
279
+ ```ruby
280
+ validator = PayuPl::Webhooks::Validator.new(request, logger: Rails.logger)
281
+ ```
282
+
283
+ This logs:
284
+ - Signature verification steps
285
+ - Algorithm used
286
+ - Payload parsing
287
+ - Any errors
288
+
289
+ ### 5. Test Webhook Integration
290
+
291
+ #### Manual Testing
292
+
293
+ Use curl to simulate a webhook:
294
+
295
+ ```bash
296
+ # Generate signature (example for MD5)
297
+ BODY='{"order":{"orderId":"TEST123","status":"COMPLETED"}}'
298
+ KEY='your_second_key'
299
+ SIGNATURE=$(echo -n "${BODY}${KEY}" | md5sum | cut -d' ' -f1)
300
+
301
+ # Send webhook
302
+ curl -X POST http://localhost:3000/webhooks/payu \
303
+ -H "Content-Type: application/json" \
304
+ -H "OpenPayU-Signature: signature=${SIGNATURE};algorithm=MD5" \
305
+ -d "${BODY}"
306
+ ```
307
+
308
+ #### Using PayU Sandbox
309
+
310
+ 1. Create an order in sandbox
311
+ 2. Configure notifyUrl to your webhook endpoint
312
+ 3. PayU will send real webhook notifications
313
+ 4. Use a tool like ngrok to expose local server
314
+
315
+ ## Signature Algorithms
316
+
317
+ PayU supports multiple signature algorithms:
318
+
319
+ - **MD5** - PayU may use `MD5(body + key)` or `MD5(key + body)`
320
+ - Validator checks both automatically
321
+ - **SHA1** - HMAC-SHA1
322
+ - **SHA256** - HMAC-SHA256 (recommended)
323
+ - **SHA384** - HMAC-SHA384
324
+ - **SHA512** - HMAC-SHA512
325
+
326
+ The validator automatically detects the algorithm from the webhook header.
327
+
328
+ ## Troubleshooting
329
+
330
+ ### "Missing OpenPayU signature header"
331
+
332
+ - PayU didn't send the signature header
333
+ - Check if request is actually from PayU
334
+ - Verify your notifyUrl is correct
335
+
336
+ ### "Signature verification failed"
337
+
338
+ - Wrong second_key configured
339
+ - Payload was modified in transit
340
+ - Algorithm mismatch
341
+
342
+ **Debug steps**:
343
+ 1. Enable logging to see expected vs received signature
344
+ 2. Verify second_key matches PayU merchant panel
345
+ 3. Check raw request body hasn't been modified
346
+
347
+ ### "PayU second_key not configured"
348
+
349
+ - No configuration found
350
+ - Set via `PayuPl.configure`, ENV, or pass directly
351
+
352
+ ### Tests Failing
353
+
354
+ Ensure rack gem is available for tests:
355
+
356
+ ```ruby
357
+ # Gemfile
358
+ group :test do
359
+ gem 'rack', '~> 3.0'
360
+ end
361
+ ```
362
+
363
+ ## Security Considerations
364
+
365
+ 1. **Always verify signatures** - Never trust webhook data without verification
366
+ 2. **Keep second_key secret** - Don't commit to version control
367
+ 3. **Use HTTPS** - Configure notifyUrl with https://
368
+ 4. **Validate IP addresses** (optional) - PayU webhooks come from known IPs
369
+ 5. **Rate limiting** (optional) - Protect against abuse
370
+
371
+ ## Additional Resources
372
+
373
+ - [PayU Webhook Documentation](https://developers.payu.com/europe/docs/webhooks/)
374
+ - [PayU API Reference](https://developers.payu.com/europe/api/)
375
+ - [Sandbox Testing Guide](https://developers.payu.com/europe/docs/testing/sandbox/)
376
+
377
+ ## Support
378
+
379
+ For issues with this gem:
380
+ - GitHub Issues: https://github.com/dawidof/payu_pl/issues
381
+
382
+ For PayU API issues:
383
+ - PayU Support: https://www.payu.pl/pomoc
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example Rack app for handling PayU webhooks
4
+ #
5
+ # This shows how to use the webhook validator in a plain Rack application
6
+ #
7
+ # To use this:
8
+ # 1. Set PAYU_SECOND_KEY environment variable
9
+ # 2. Run with: rackup examples/rack_webhook_example.ru
10
+ #
11
+ # Or integrate the middleware into your existing Rack app
12
+
13
+ require 'payu_pl'
14
+ require 'json'
15
+
16
+ class PayuWebhookApp
17
+ def call(env)
18
+ request = Rack::Request.new(env)
19
+
20
+ # Only handle POST to /webhooks/payu
21
+ unless request.post? && request.path == '/webhooks/payu'
22
+ return [404, { 'Content-Type' => 'text/plain' }, ['Not Found']]
23
+ end
24
+
25
+ # Validate webhook signature and parse payload
26
+ validator = PayuPl::Webhooks::Validator.new(request)
27
+ result = validator.validate_and_parse
28
+
29
+ if result.failure?
30
+ puts "ERROR: PayU webhook validation failed: #{result.error}"
31
+ return [400, { 'Content-Type' => 'text/plain' }, ['Bad Request']]
32
+ end
33
+
34
+ # Extract webhook data
35
+ payload = result.data
36
+ order_id = payload.dig('order', 'orderId')
37
+ status = payload.dig('order', 'status')
38
+
39
+ puts "INFO: PayU webhook received - Order: #{order_id}, Status: #{status}"
40
+
41
+ # Process the webhook
42
+ # Your business logic here...
43
+
44
+ [200, { 'Content-Type' => 'text/plain' }, ['OK']]
45
+ rescue StandardError => e
46
+ puts "ERROR: PayU webhook processing error: #{e.class} - #{e.message}"
47
+ [500, { 'Content-Type' => 'text/plain' }, ['Internal Server Error']]
48
+ end
49
+ end
50
+
51
+ # Optional: Add logging middleware
52
+ class LoggingMiddleware
53
+ def initialize(app)
54
+ @app = app
55
+ end
56
+
57
+ def call(env)
58
+ request = Rack::Request.new(env)
59
+ puts "#{Time.now} - #{request.request_method} #{request.path}"
60
+ @app.call(env)
61
+ end
62
+ end
63
+
64
+ # Build the Rack app
65
+ use LoggingMiddleware
66
+ run PayuWebhookApp.new
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example Sinatra app for handling PayU webhooks
4
+ #
5
+ # To use this:
6
+ # 1. Install sinatra: gem install sinatra
7
+ # 2. Set PAYU_SECOND_KEY environment variable
8
+ # 3. Run: ruby examples/sinatra_webhook_example.rb
9
+ #
10
+ # Or integrate into your existing Sinatra app
11
+
12
+ require 'sinatra'
13
+ require 'payu_pl'
14
+ require 'json'
15
+
16
+ # Configure PayU (optional - can also use ENV['PAYU_SECOND_KEY'])
17
+ PayuPl.configure do |config|
18
+ config.second_key = ENV['PAYU_SECOND_KEY']
19
+ end
20
+
21
+ # PayU webhook endpoint
22
+ post '/webhooks/payu' do
23
+ # Validate webhook signature and parse payload
24
+ result = PayuPl::Webhooks::Validator.new(request).validate_and_parse
25
+
26
+ if result.failure?
27
+ logger.error("PayU webhook validation failed: #{result.error}")
28
+ status 400
29
+ return
30
+ end
31
+
32
+ # Extract webhook data
33
+ payload = result.data
34
+ order_id = payload.dig('order', 'orderId')
35
+ status_value = payload.dig('order', 'status')
36
+
37
+ logger.info("PayU webhook - Order: #{order_id}, Status: #{status_value}")
38
+
39
+ # Process the webhook
40
+ # Your business logic here...
41
+
42
+ status 200
43
+ end
44
+
45
+ # Health check endpoint
46
+ get '/health' do
47
+ content_type :json
48
+ { status: 'ok' }.to_json
49
+ end
50
+
51
+ # Start the server (for standalone usage)
52
+ if __FILE__ == $0
53
+ set :port, 4567
54
+ set :bind, '0.0.0.0'
55
+
56
+ puts "PayU webhook listener starting on http://0.0.0.0:4567"
57
+ puts "Webhook endpoint: POST /webhooks/payu"
58
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example Rails controller for handling PayU webhooks
4
+ #
5
+ # To use this in your Rails app:
6
+ # 1. Copy this file to app/controllers/webhooks/payu_controller.rb
7
+ # 2. Add route: post '/webhooks/payu', to: 'webhooks/payu#create'
8
+ # 3. Configure PayU second_key in config/initializers/payu.rb
9
+ #
10
+ # Note: This is just an example. Adjust to your application's needs.
11
+
12
+ module Webhooks
13
+ class PayuController < ApplicationController
14
+ # Skip CSRF token verification for webhooks
15
+ skip_before_action :verify_authenticity_token
16
+
17
+ def create
18
+ # Validate webhook signature and parse payload
19
+ result = PayuPl::Webhooks::Validator.new(request, logger: Rails.logger).validate_and_parse
20
+
21
+ if result.failure?
22
+ Rails.logger.error("PayU webhook validation failed: #{result.error}")
23
+ return head :bad_request
24
+ end
25
+
26
+ # Extract webhook data
27
+ payload = result.data
28
+ order_id = payload.dig('order', 'orderId')
29
+ status = payload.dig('order', 'status')
30
+ event_id = payload['eventId'] || "#{order_id}_#{status}"
31
+
32
+ # Format amount (PayU sends in minor units: 2900 = 29.00 PLN)
33
+ total_amount = payload.dig('order', 'totalAmount').to_i
34
+ currency = payload.dig('order', 'currencyCode')
35
+ formatted_amount = "%.2f" % (total_amount / 100.0)
36
+
37
+ Rails.logger.info("PayU webhook received - Order: #{order_id}, Status: #{status}")
38
+ Rails.logger.info("Amount: #{formatted_amount} #{currency}")
39
+
40
+ # Optional: Log PayU processing time for monitoring
41
+ if request.headers['PayU-Processing-Time']
42
+ Rails.logger.info("PayU processing time: #{request.headers['PayU-Processing-Time']}ms")
43
+ end
44
+
45
+ # Check for duplicate events (optional but recommended)
46
+ # You'll need a WebhookEvent model or similar to track processed events
47
+ # if WebhookEvent.exists?(provider: 'payu', event_id: event_id)
48
+ # Rails.logger.warn("Duplicate PayU webhook event: #{event_id}")
49
+ # return head :ok
50
+ # end
51
+
52
+ # Store the webhook event (optional)
53
+ # WebhookEvent.create!(
54
+ # provider: 'payu',
55
+ # event_id: event_id,
56
+ # event_type: status,
57
+ # payload: payload
58
+ # )
59
+
60
+ # Process the webhook asynchronously (recommended)
61
+ # PayuWebhookJob.perform_async(order_id, status, payload)
62
+
63
+ # Or process synchronously (not recommended for production)
64
+ # process_webhook(order_id, status, payload)
65
+
66
+ head :ok
67
+ rescue StandardError => e
68
+ # Log unexpected errors
69
+ Rails.logger.error("PayU webhook processing error: #{e.class} - #{e.message}")
70
+ Rails.logger.error(e.backtrace.first(5).join("\n"))
71
+
72
+ # Return 500 so PayU will retry
73
+ head :internal_server_error
74
+ end
75
+
76
+ private
77
+
78
+ def process_webhook(order_id, status, payload)
79
+ # Your business logic here
80
+ # For example:
81
+ # - Update order status in your database
82
+ # - Send confirmation emails
83
+ # - Trigger fulfillment processes
84
+ # - etc.
85
+
86
+ case status
87
+ when 'PENDING'
88
+ Rails.logger.info("Order #{order_id} is pending payment")
89
+ when 'WAITING_FOR_CONFIRMATION'
90
+ Rails.logger.info("Order #{order_id} is waiting for manual capture")
91
+ # This status appears when auto-receive is disabled
92
+ # You need to manually capture or cancel the order
93
+ when 'COMPLETED'
94
+ Rails.logger.info("Order #{order_id} completed successfully")
95
+ # Update your order status, trigger fulfillment, etc.
96
+ when 'CANCELED'
97
+ Rails.logger.info("Order #{order_id} was canceled")
98
+ # Handle cancellation
99
+ else
100
+ Rails.logger.warn("Unknown status #{status} for order #{order_id}")
101
+ end
102
+ end
103
+ end
104
+ end
@@ -4,10 +4,11 @@ require "i18n"
4
4
 
5
5
  module PayuPl
6
6
  class Configuration
7
- attr_accessor :locale
7
+ attr_accessor :locale, :second_key
8
8
 
9
9
  def initialize
10
10
  @locale = :en
11
+ @second_key = nil
11
12
  end
12
13
  end
13
14
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PayuPl
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PayuPl
4
+ module Webhooks
5
+ # Result class for webhook validation responses
6
+ # Provides a consistent interface for success and failure cases
7
+ class Result
8
+ attr_reader :data, :error
9
+
10
+ def initialize(data = nil, error = nil)
11
+ @data = data
12
+ @error = error
13
+ end
14
+
15
+ def self.success(data)
16
+ new(data, nil)
17
+ end
18
+
19
+ def self.failure(error)
20
+ new(nil, error)
21
+ end
22
+
23
+ def success?
24
+ error.nil?
25
+ end
26
+
27
+ def failure?
28
+ !success?
29
+ end
30
+ end
31
+ end
32
+ end