spree_razorpay_checkout 0.3.0 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2ae9eca9f07041baff888721f0da631374e73925e8237eeafe32ccfe5185298
4
- data.tar.gz: b087fa0f6961cc60c77c38cef002566104a19f6a3a87519658d69c2eb0174ec0
3
+ metadata.gz: 59ea8bc12e162b79eacdbefce1a1ee167cd9a4d4e7c2cd7cd3bd53ce47a8d716
4
+ data.tar.gz: a4b9a022d525bb254c0b932f415ebedf63dbfc4aac3be42d648546163b149d87
5
5
  SHA512:
6
- metadata.gz: 4262af60d12805b8d7ee60cae23b03f95d9211a806bc39d88d8a4cbe556e280428921d1c9c14b940f56d92128b1e952e7ea99f0d69436bb00071b16c425b83e5
7
- data.tar.gz: bf60c0322601d7dfed075e214828ab5fabf98f55a57760ab3f7b73c9b40edc3c3da7a19ac04ac5ff267b23f8130171332c4a522da996533832d1568e23f0652d
6
+ metadata.gz: cc7bedce60a230b32703ff7e8613153f9eee91eee5554b51c42e7de2aa235799038825b5b103eddd4948e26e9ac8a13850d2353e1d240b514b839dd58c686fc2
7
+ data.tar.gz: eb72bd494b0f178939f8ad4463281ec3bb793f8bf6ef0bb0c751b8d1fa2c976ec1b42b1a9a81b774fff1680c411b783f2a2c31327f679c1d842a35dd63ce6bc1
@@ -0,0 +1,78 @@
1
+ module Spree
2
+ class RazorpayWebhooksController < ActionController::API
3
+ skip_before_action :verify_authenticity_token, raise: false
4
+
5
+ # Handles asynchronous background webhooks directly from Razorpay's servers
6
+ def create
7
+ payload = request.body.read
8
+ signature = request.headers['X-Razorpay-Signature'] || request.headers['HTTP_X_RAZORPAY_SIGNATURE']
9
+
10
+ gateway = Spree::Gateway::RazorpayGateway.active.first
11
+ return head :not_found unless gateway && gateway.preferred_webhook_secret.present?
12
+
13
+ begin
14
+ # 1. Use the gateway's parser to verify signature and map the action
15
+ parsed_event = gateway.parse_webhook_event(payload, { 'HTTP_X_RAZORPAY_SIGNATURE' => signature })
16
+
17
+ # If it's an event we don't care about, acknowledge and return
18
+ return head :ok unless parsed_event
19
+
20
+ # 2. Extract Data
21
+ payment_session = parsed_event[:payment_session]
22
+ action = parsed_event[:action]
23
+
24
+ event_data = JSON.parse(payload)
25
+ metadata = event_data.dig('payload', 'payment', 'entity') || {}
26
+
27
+ # 3. Trigger the Core Spree 5.5 Webhook Handler Service
28
+ Spree::Payments::HandleWebhook.call(
29
+ payment_method: gateway,
30
+ action: action,
31
+ payment_session: payment_session,
32
+ metadata: metadata
33
+ )
34
+
35
+ rescue Spree::PaymentMethod::WebhookSignatureError => e
36
+ Rails.logger.error("Razorpay Webhook Verification Failed: #{e.message}")
37
+ return head :unauthorized
38
+ rescue StandardError => e
39
+ Rails.logger.error("Razorpay Webhook Processing Error: #{e.message}")
40
+ return head :unprocessable_entity
41
+ end
42
+
43
+ head :ok
44
+ end
45
+
46
+ # Handles synchronous frontend verification from Next.js Storefront
47
+ def verify
48
+ razorpay_order_id = params[:razorpay_order_id]
49
+ razorpay_payment_id = params[:razorpay_payment_id]
50
+ razorpay_signature = params[:razorpay_signature]
51
+
52
+ session = Spree::PaymentSession.find_by(external_id: razorpay_order_id)
53
+ return head :not_found unless session
54
+
55
+ gateway = Spree::Gateway::RazorpayGateway.active.first
56
+ return render json: { success: false, error: 'Gateway not configured' }, status: :internal_server_error unless gateway
57
+ gateway.provider
58
+
59
+ begin
60
+ ::Razorpay::Utility.verify_payment_signature(
61
+ razorpay_order_id: razorpay_order_id,
62
+ razorpay_payment_id: razorpay_payment_id,
63
+ razorpay_signature: razorpay_signature
64
+ )
65
+
66
+ session.external_data ||= {}
67
+ session.external_data['razorpay_payment_id'] = razorpay_payment_id
68
+ session.external_data['razorpay_signature'] = razorpay_signature
69
+ session.save!
70
+
71
+ render json: { success: true }
72
+ rescue ::Razorpay::Errors::SignatureVerificationError => e
73
+ Rails.logger.error("Razorpay Frontend Verification Failed: #{e.message}")
74
+ render json: { success: false, error: e.message }, status: :unprocessable_entity
75
+ end
76
+ end
77
+ end
78
+ end
@@ -14,6 +14,9 @@ module Spree
14
14
  preference :merchant_address, :string, default: 'Razorpay, Bangalore, India'
15
15
  preference :theme_color, :string, default: '#2e5bff'
16
16
  preference :headless_api_mode, :boolean, default: false
17
+
18
+ # NEW: Allow manual capture vs auto-capture choice from Spree Admin Panel
19
+ preference :auto_capture, :boolean, default: true
17
20
 
18
21
  def name
19
22
  'Razorpay Secure (UPI, Wallets, Cards & Netbanking)'
@@ -52,7 +55,7 @@ module Spree
52
55
  end
53
56
 
54
57
  def auto_capture?
55
- true
58
+ preferred_auto_capture
56
59
  end
57
60
 
58
61
  def request_type
@@ -95,7 +98,9 @@ module Spree
95
98
  Spree::PaymentSessions::Razorpay if defined?(Spree::PaymentSession)
96
99
  end
97
100
 
101
+ # =========================================================================
98
102
  # SPREE 5.4+ HEADLESS API FLOW (Protected by defined? check)
103
+ # =========================================================================
99
104
 
100
105
  if defined?(Spree::PaymentSession)
101
106
  def create_payment_session(order:, amount: nil, external_data: {})
@@ -107,7 +112,7 @@ module Spree
107
112
  amount: amount_in_cents,
108
113
  currency: order.currency || 'INR',
109
114
  receipt: order.number,
110
- payment_capture: 1,
115
+ payment_capture: auto_capture? ? 1 : 0, # UPDATED: Respects Manual Capture
111
116
  notes: { spree_order_number: order.number, email: order.email }
112
117
  )
113
118
 
@@ -140,7 +145,7 @@ module Spree
140
145
  amount: amount_in_cents,
141
146
  currency: payment_session.currency,
142
147
  receipt: payment_session.order.number,
143
- payment_capture: 1
148
+ payment_capture: auto_capture? ? 1 : 0 # UPDATED: Respects Manual Capture
144
149
  )
145
150
 
146
151
  payment_session.update!(amount: amount, external_id: new_rzp_order.id)
@@ -151,34 +156,60 @@ module Spree
151
156
  def complete_payment_session(payment_session:, params: {})
152
157
  provider
153
158
 
154
- ext_data = params[:external_data] || params['external_data'] || {}
155
- rzp_payment_id = ext_data[:razorpay_payment_id] || ext_data['razorpay_payment_id'] || payment_session.external_data['razorpay_payment_id']
156
- rzp_signature = ext_data[:razorpay_signature] || ext_data['razorpay_signature'] || payment_session.external_data['razorpay_signature']
159
+ # Robustly parse the JSON string sent from Next.js via the Spree API
160
+ result_data = {}
161
+ if params[:session_result].present?
162
+ begin
163
+ result_data = params[:session_result].is_a?(String) ? JSON.parse(params[:session_result]) : params[:session_result]
164
+ rescue JSON::ParserError
165
+ result_data = {}
166
+ end
167
+ end
168
+
169
+ rzp_payment_id = result_data['razorpay_payment_id'] || result_data[:razorpay_payment_id] || payment_session.external_data['razorpay_payment_id']
170
+ rzp_signature = result_data['razorpay_signature'] || result_data[:razorpay_signature] || payment_session.external_data['razorpay_signature']
157
171
 
158
172
  begin
173
+ # Verify the Signature
159
174
  ::Razorpay::Utility.verify_payment_signature(
160
175
  razorpay_order_id: payment_session.external_id,
161
176
  razorpay_payment_id: rzp_payment_id,
162
177
  razorpay_signature: rzp_signature
163
178
  )
164
179
 
165
- rzp_payment = ::Razorpay::Payment.fetch(rzp_payment_id)
166
- if rzp_payment.status == 'authorized'
167
- rzp_payment.capture({ amount: (payment_session.amount.to_f * 100).to_i })
168
- end
169
-
170
- payment_session.process! if payment_session.can_process?
171
- payment = payment_session.find_or_create_payment!
172
-
173
- if payment.present? && !payment.completed?
174
- payment.started_processing! if payment.checkout?
175
- payment.complete! if payment.can_complete?
180
+ # Lock the order database row to prevent race conditions with Webhooks
181
+ payment_session.order.with_lock do
182
+
183
+ # Save the verified IDs into the session
184
+ session_data = payment_session.external_data || {}
185
+ session_data['razorpay_payment_id'] = rzp_payment_id
186
+ session_data['razorpay_signature'] = rzp_signature
187
+ payment_session.update!(external_data: session_data)
188
+
189
+ # Fetch payment status directly from Razorpay for instant frontend resolution
190
+ rzp_payment = ::Razorpay::Payment.fetch(rzp_payment_id)
191
+
192
+ # Process and Create Payment
193
+ payment_session.process! if payment_session.can_process?
194
+
195
+ payment = payment_session.find_or_create_payment!
196
+
197
+ if payment.present? && !payment.completed?
198
+ payment.started_processing! if payment.checkout?
199
+
200
+ # SYNCHRONOUS FIX: Immediately transition payment state so Next.js can complete the order
201
+ if rzp_payment.status == 'captured'
202
+ payment.complete! if payment.can_complete?
203
+ elsif rzp_payment.status == 'authorized'
204
+ payment.pend! if payment.can_pend?
205
+ end
206
+ end
207
+
208
+ payment_session.complete! if payment_session.can_complete?
176
209
  end
177
210
 
178
- payment_session.complete! if payment_session.can_complete?
179
-
180
211
  rescue StandardError => e
181
- Rails.logger.error("Razorpay 5.4 API Completion Failed: #{e.message}")
212
+ Rails.logger.error("Razorpay 5.4+ API Completion Failed: #{e.message}")
182
213
  payment_session.fail! if payment_session.can_fail?
183
214
  raise Spree::Core::GatewayError, e.message
184
215
  end
@@ -198,9 +229,12 @@ module Spree
198
229
  session = Spree::PaymentSession.find_by(external_id: payment_entity['order_id'])
199
230
  return nil unless session
200
231
 
232
+ # UPDATED: Split authorized vs captured to align with new Spree webhook architecture
201
233
  case event['event']
202
- when 'payment.captured', 'payment.authorized'
234
+ when 'payment.captured', 'order.paid'
203
235
  { action: :captured, payment_session: session }
236
+ when 'payment.authorized'
237
+ { action: :authorized, payment_session: session }
204
238
  when 'payment.failed'
205
239
  { action: :failed, payment_session: session }
206
240
  else
@@ -211,6 +245,10 @@ module Spree
211
245
  end
212
246
  end
213
247
 
248
+ # =========================================================================
249
+ # LEGACY / ACTIVEMERCHANT FLOW
250
+ # =========================================================================
251
+
214
252
  def purchase(_amount, source, _gateway_options = {})
215
253
  provider
216
254
 
@@ -240,7 +278,7 @@ module Spree
240
278
  end
241
279
  end
242
280
 
243
- def resolve_razorpay_payment_id(response_code)
281
+ def resolve_razorpay_payment_id(response_code)
244
282
  return nil if response_code.blank?
245
283
 
246
284
  if response_code.to_s.start_with?('order_')
@@ -257,8 +295,24 @@ def resolve_razorpay_payment_id(response_code)
257
295
  end
258
296
  end
259
297
 
260
- def capture(*args)
261
- ActiveMerchant::Billing::Response.new(true, 'Already Captured', {}, test: preferred_test_mode)
298
+ # UPDATED: Fully implemented Capture method so you can click "Capture" inside Spree Admin
299
+ def capture(amount_in_cents, response_code, gateway_options = {})
300
+ provider
301
+ begin
302
+ rzp_payment_id = resolve_razorpay_payment_id(response_code)
303
+
304
+ raise StandardError, "Missing Razorpay Payment ID." if rzp_payment_id.blank?
305
+
306
+ rzp_payment = ::Razorpay::Payment.fetch(rzp_payment_id)
307
+ if rzp_payment.status == 'authorized'
308
+ rzp_payment.capture({ amount: amount_in_cents, currency: gateway_options[:currency] || 'INR' })
309
+ end
310
+
311
+ ActiveMerchant::Billing::Response.new(true, 'Razorpay Capture Successful', {}, test: preferred_test_mode, authorization: rzp_payment_id)
312
+ rescue StandardError => e
313
+ Rails.logger.error("Razorpay Capture Failed: #{e.message}")
314
+ ActiveMerchant::Billing::Response.new(false, "Capture failed: #{e.message}", {}, test: preferred_test_mode)
315
+ end
262
316
  end
263
317
 
264
318
  def credit(credit_cents, response_code, _gateway_options = {})
@@ -301,4 +355,4 @@ def resolve_razorpay_payment_id(response_code)
301
355
  void(response_code)
302
356
  end
303
357
  end
304
- end
358
+ end
@@ -9,6 +9,42 @@ module Spree
9
9
  def razorpay_order_id
10
10
  external_id
11
11
  end
12
+
13
+ def find_or_create_payment!(metadata = {})
14
+ return unless persisted?
15
+ return payment if payment.present?
16
+
17
+ order.with_lock do
18
+ rzp_payment_id = external_data['razorpay_payment_id'] || metadata['id']
19
+
20
+ existing_payment = order.payments.where(
21
+ payment_method: payment_method,
22
+ response_code: rzp_payment_id || external_id
23
+ ).first
24
+
25
+ return existing_payment if existing_payment.present?
26
+
27
+ rzp_status = metadata['status'] || 'pending'
28
+
29
+ source = ::Spree::RazorpayCheckout.create!(
30
+ order_id: order.id,
31
+ razorpay_payment_id: rzp_payment_id,
32
+ razorpay_order_id: external_id,
33
+ razorpay_signature: external_data['razorpay_signature'],
34
+ status: rzp_status,
35
+ payment_method: payment_method.name
36
+ )
37
+
38
+ order.payments.create!(
39
+ payment_method: payment_method,
40
+ amount: amount,
41
+ response_code: rzp_payment_id || external_id,
42
+ source: source,
43
+ skip_source_requirement: true
44
+ )
45
+ end
46
+ end
47
+
12
48
  end
13
49
  end
14
- end
50
+ end
@@ -47,7 +47,7 @@
47
47
  <h6 class="font-weight-bold text-dark mb-2" style="font-size: 0.95rem;">Next.js Storefront</h6>
48
48
 
49
49
  <div class="input-group bg-gray-25 border border-gray-100 mb-3 d-flex align-items-center rounded" data-controller="clipboard" data-clipboard-success-content-value="Copied!">
50
- <input type="text" readonly value="<%= base_url %>/api/v3/webhooks/payments/<%= @payment_method.id %>" class="text-gray-600 py-2 grow pl-3 border-0 bg-transparent shadow-none" style="font-family: monospace; font-size: 0.85rem; outline: none; min-width: 0;" data-clipboard-target="source">
50
+ <input type="text" readonly value="<%= base_url %>/api/v3/webhooks/payments/<%= @payment_method.prefixed_id %>" class="text-gray-600 py-2 grow pl-3 border-0 bg-transparent shadow-none" style="font-family: monospace; font-size: 0.85rem; outline: none; min-width: 0;" data-clipboard-target="source">
51
51
  <button type="button" class="btn btn-sm btn-light mr-1 d-flex align-items-center justify-content-center p-1" title="Copy to clipboard" data-action="clipboard#copy" data-clipboard-target="button">
52
52
  <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 24 24" class="text-muted">
53
53
  <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 4h3a1 1 0 0 1 1 1v15a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3m0 3h6m-6 5h6m-6 4h6M10 3v4h4V3h-4Z"/>
data/config/routes.rb CHANGED
@@ -1,7 +1,4 @@
1
1
  Spree::Core::Engine.add_routes do
2
-
3
- namespace :razorpay do
4
- post :webhooks, to: 'webhooks#create'
5
- post :verify, to: 'webhooks#verify'
6
- end
7
- end
2
+ post '/razorpay/webhooks', to: 'razorpay_webhooks#create'
3
+ post '/razorpay/verify', to: 'razorpay_webhooks#verify'
4
+ end
@@ -1,5 +1,5 @@
1
1
  module SpreeRazorpayCheckout
2
- VERSION = '0.3.0'.freeze
2
+ VERSION = '0.3.2'.freeze
3
3
 
4
4
  module_function
5
5
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_razorpay_checkout
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Umesh Ravani
@@ -79,7 +79,7 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
- description: Seamless Razorpay checkout integration for Spree 5.4. Features include
82
+ description: Seamless Razorpay checkout integration for Spree 5.5. Features include
83
83
  Hotwire/Turbo compatibility, Zero Drop-off Webhook captures, native Spree Dashboard
84
84
  refunds, and the Affordability Widget.
85
85
  email:
@@ -95,7 +95,7 @@ files:
95
95
  - app/assets/images/payment_icons/window_modal.svg
96
96
  - app/controllers/concerns/spree/razor_pay.rb
97
97
  - app/controllers/spree/products_controller_decorator.rb
98
- - app/controllers/spree/razorpay/webhooks_controller.rb
98
+ - app/controllers/spree/razorpay/_webhooks_controller.rb
99
99
  - app/controllers/spree/razorpay_controller.rb
100
100
  - app/javascript/spree_razorpay/application.js
101
101
  - app/javascript/spree_razorpay/controllers/checkout_razorpay_controller.js
@@ -1,72 +0,0 @@
1
- module Spree
2
- module Razorpay
3
- class WebhooksController < ActionController::API
4
- skip_before_action :verify_authenticity_token, raise: false
5
-
6
- # Handles asynchronous background webhooks directly from Razorpay's servers
7
- def create
8
- payload = request.body.read
9
- signature = request.headers['X-Razorpay-Signature']
10
-
11
- gateway = Spree::Gateway::RazorpayGateway.active.first
12
- return head :not_found unless gateway && gateway.preferred_webhook_secret.present?
13
-
14
- begin
15
- ::Razorpay::Utility.verify_webhook_signature(
16
- payload,
17
- signature,
18
- gateway.preferred_webhook_secret
19
- )
20
- rescue ::Razorpay::Errors::SignatureVerificationError => e
21
- Rails.logger.error("Razorpay Webhook Verification Failed: #{e.message}")
22
- return head :unauthorized
23
- end
24
-
25
- event = JSON.parse(payload)
26
-
27
- # Listen for 'payment.authorized' which fires immediately after OTP!
28
- if ['order.paid', 'payment.captured', 'payment.authorized'].include?(event['event'])
29
- ::SpreeRazorpayCheckout::HandleWebhookEventJob.perform_later(event)
30
- end
31
-
32
- head :ok
33
- end
34
-
35
- # Handles synchronous frontend verification from Next.js Storefront
36
- def verify
37
- razorpay_order_id = params[:razorpay_order_id]
38
- razorpay_payment_id = params[:razorpay_payment_id]
39
- razorpay_signature = params[:razorpay_signature]
40
-
41
- session = Spree::PaymentSession.find_by(external_id: razorpay_order_id)
42
- return head :not_found unless session
43
-
44
- # Initialize the Razorpay gem with your active API keys
45
- gateway = Spree::Gateway::RazorpayGateway.active.first
46
- return render json: { success: false, error: 'Gateway not configured' }, status: :internal_server_error unless gateway
47
- gateway.provider
48
-
49
- begin
50
- # 1. Verify the signature securely on the server
51
- ::Razorpay::Utility.verify_payment_signature(
52
- razorpay_order_id: razorpay_order_id,
53
- razorpay_payment_id: razorpay_payment_id,
54
- razorpay_signature: razorpay_signature
55
- )
56
-
57
- # 2. Inject the signatures into the session's external_data
58
- session.external_data ||= {}
59
- session.external_data['razorpay_payment_id'] = razorpay_payment_id
60
- session.external_data['razorpay_signature'] = razorpay_signature
61
- session.save!
62
-
63
- render json: { success: true }
64
- rescue ::Razorpay::Errors::SignatureVerificationError => e
65
- Rails.logger.error("Razorpay Frontend Verification Failed: #{e.message}")
66
- render json: { success: false, error: e.message }, status: :unprocessable_entity
67
- end
68
- end
69
-
70
- end
71
- end
72
- end