spree_vpago 2.4.2 → 2.4.3

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: af2ee300102ba9c6ca000d69c338ec7b2775d6b367dd0b38ea7c9ae65ccb47f1
4
- data.tar.gz: f28c459ecab0f6ec6053342a525ad8b4d525ae840aa6a8fb902ddee246edec98
3
+ metadata.gz: c60cd32b8e9acf6a13c8f3190521841f2305566a750750dd8ae050eb65728859
4
+ data.tar.gz: 0c7dfd54ac257faa8ec0690cb529d2c42ac74dfe4f4103daf16f39a95794818d
5
5
  SHA512:
6
- metadata.gz: d468af9361fc9c5055d3523e23df57d24a55d62a14cfc2f3d769e59817c92cff9233f70cd54e49c2d618ebe6ee165205c39278afaaf7c43490a1db9fef4656e1
7
- data.tar.gz: 3bb37aade93a26049c3aba23a230fc5f337dea599418690583ea774f0c9a0f26b55041ef1414745dc5c3b5c63f97cc78525ce64bf35ef1a8a2721093137d020b
6
+ metadata.gz: 7306bfdaa203f43b7bd28e1f8237efb3b4c6c72dc98160d6e1ec6afdddace2871ca790fc133b5dde2cc6b7e4703fdcdbf0ed99b2d3f5e258f2a3be20b7a38657
7
+ data.tar.gz: da6a16929e7b9b11427e922ceb8dafdc236ec876e9047666eb062700b767b0030b75ee2fa15543fe3f03ed5a9920ad2efe79add77f4fc3c5471aa918f2081ac1
@@ -15,7 +15,9 @@ on:
15
15
  - "*"
16
16
  jobs:
17
17
  test_and_publish_gem:
18
- runs-on: ubuntu-latest
18
+ # Pinned: ubuntu-latest moved to 26.04, where setup-ruby only ships the latest patch of each series, so
19
+ # ruby 3.2.0 below 404s. Bump both together.
20
+ runs-on: ubuntu-24.04
19
21
 
20
22
  services:
21
23
  db:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- spree_vpago (2.4.2)
4
+ spree_vpago (2.4.3)
5
5
  faraday
6
6
  google-cloud-firestore
7
7
  spree_api (>= 4.5)
data/Rakefile CHANGED
@@ -18,4 +18,11 @@ desc 'Generates a dummy app for testing'
18
18
  task :test_app do
19
19
  ENV['LIB_NAME'] = 'spree_vpago'
20
20
  Rake::Task['extension:test_app'].invoke
21
+
22
+ # spree_backend 4.5's installer runs an unpinned `yarn add @spree/dashboard`, which now resolves to Spree 6's
23
+ # React dashboard that this dummy app's esbuild can't bundle. spree-dashboard.js is then never built and every
24
+ # spec rendering a page fails, so pin the last 4.x-compatible release and rebuild.
25
+ Dir.chdir(File.expand_path('spec/dummy', __dir__)) do
26
+ system('yarn add @spree/dashboard@0.2.1 && yarn build && bundle exec rake assets:precompile', exception: true)
27
+ end
21
28
  end
@@ -13689,6 +13689,11 @@ This typically indicates that your device does not have a healthy Internet conne
13689
13689
  };
13690
13690
 
13691
13691
  // app/javascripts/vpago/vpago_payments/user_informers/firebase.js
13692
+ var CAPTURE_PENDING_CODES = [
13693
+ "payment_is_capturing",
13694
+ "payment_capture_retrying",
13695
+ "payment_capture_unconfirmed"
13696
+ ];
13692
13697
  async function listenToProcessingState({
13693
13698
  firebaseConfigs,
13694
13699
  documentReferencePath,
@@ -13696,6 +13701,9 @@ This typically indicates that your device does not have a healthy Internet conne
13696
13701
  onPaymentIsRetrying,
13697
13702
  onOrderIsProcessing,
13698
13703
  onOrderIsCompleted,
13704
+ onPaymentIsCapturing,
13705
+ onPaymentCaptureRetrying,
13706
+ onPaymentCaptureUnconfirmed,
13699
13707
  onOrderProcessFailed,
13700
13708
  onPaymentProcessFailed,
13701
13709
  onCompleted
@@ -13716,7 +13724,7 @@ This typically indicates that your device does not have a healthy Internet conne
13716
13724
  if (window.parent !== window) {
13717
13725
  window.parent.postMessage(messageCode, "*");
13718
13726
  }
13719
- let orderCompleted = orderState === "complete";
13727
+ let orderCompleted = orderState === "complete" && !CAPTURE_PENDING_CODES.includes(messageCode);
13720
13728
  if (orderCompleted) {
13721
13729
  queueProcessor.queueStateChange({
13722
13730
  minDelayInMs: 1500,
@@ -13788,6 +13796,48 @@ This typically indicates that your device does not have a healthy Internet conne
13788
13796
  }
13789
13797
  });
13790
13798
  break;
13799
+ case "payment_is_capturing":
13800
+ queueProcessor.queueStateChange({
13801
+ minDelayInMs: 1500,
13802
+ callback: async () => {
13803
+ await onPaymentIsCapturing?.(
13804
+ orderState,
13805
+ paymentState,
13806
+ processing,
13807
+ reasonCode,
13808
+ reasonMessage
13809
+ );
13810
+ }
13811
+ });
13812
+ break;
13813
+ case "payment_capture_retrying":
13814
+ queueProcessor.queueStateChange({
13815
+ minDelayInMs: 1500,
13816
+ callback: async () => {
13817
+ await onPaymentCaptureRetrying?.(
13818
+ orderState,
13819
+ paymentState,
13820
+ processing,
13821
+ reasonCode,
13822
+ reasonMessage
13823
+ );
13824
+ }
13825
+ });
13826
+ break;
13827
+ case "payment_capture_unconfirmed":
13828
+ queueProcessor.queueStateChange({
13829
+ minDelayInMs: 1500,
13830
+ callback: async () => {
13831
+ await onPaymentCaptureUnconfirmed?.(
13832
+ orderState,
13833
+ paymentState,
13834
+ processing,
13835
+ reasonCode,
13836
+ reasonMessage
13837
+ );
13838
+ }
13839
+ });
13840
+ break;
13791
13841
  case "order_process_failed":
13792
13842
  queueProcessor.queueStateChange({
13793
13843
  minDelayInMs: 1500,
@@ -4,7 +4,7 @@ module Spree
4
4
  around_action :set_writing_role, only: %i[check_transaction]
5
5
 
6
6
  def index
7
- params[:q] = { completed_at_not_null: 1, payment_state_eq: 'balance_due', s: 'completed_at desc' }.dup if params[:q].blank?
7
+ params[:q] = default_query if params[:q].blank?
8
8
 
9
9
  @search = exclude_request_bookings(scope).ransack(params[:q])
10
10
  @orders = @search.result(distinct: true)
@@ -60,6 +60,12 @@ module Spree
60
60
  current_store.orders.accessible_by(current_ability, :index)
61
61
  end
62
62
 
63
+ # `failed` too: a pre-auth capture that failed under the old flow left its order `failed`
64
+ # (its only payment is invalid), which `balance_due` alone never listed.
65
+ def default_query
66
+ { completed_at_not_null: 1, payment_state_in: %w[balance_due failed], s: 'completed_at desc' }
67
+ end
68
+
63
69
  # Request bookings complete before the vendor confirms and the customer
64
70
  # pays, so balance_due is expected there rather than a lost payment.
65
71
  # `request_state` is added by spree_cm_commissioner, hence the column check.
@@ -32,7 +32,10 @@ module Spree
32
32
  # checker often completes the order before the browser lands here. Skipping this page
33
33
  # also skips its Firebase listener, so success must post order_is_completed itself
34
34
  # (render_success_script) or an embedding iframe modal never closes.
35
- return redirect_to @payment.success_url, allow_other_host: true if @order.completed?
35
+ #
36
+ # A pre-auth payment is the exception: its order completes on the hold, so the user (often just back
37
+ # from the bank app) stays here on "verifying" until PaymentCapturerJob captures it.
38
+ return redirect_to @payment.success_url, allow_other_host: true if @order.completed? && !@payment.awaiting_pre_auth_capture?
36
39
 
37
40
  VpagoLogger.log(label: 'Spree::VpagoPaymentsController#processing', data: vpago_log_context)
38
41
  render 'spree/vpago_shared/processing'
@@ -2,6 +2,12 @@ import { initializeApp } from "firebase/app";
2
2
  import { getFirestore, doc, onSnapshot, setDoc } from "firebase/firestore";
3
3
  import QueueProcessor from "../../../queue_processor.js";
4
4
 
5
+ const CAPTURE_PENDING_CODES = [
6
+ "payment_is_capturing",
7
+ "payment_capture_retrying",
8
+ "payment_capture_unconfirmed",
9
+ ];
10
+
5
11
  async function listenToProcessingState({
6
12
  firebaseConfigs,
7
13
  documentReferencePath,
@@ -9,6 +15,9 @@ async function listenToProcessingState({
9
15
  onPaymentIsRetrying,
10
16
  onOrderIsProcessing,
11
17
  onOrderIsCompleted,
18
+ onPaymentIsCapturing,
19
+ onPaymentCaptureRetrying,
20
+ onPaymentCaptureUnconfirmed,
12
21
  onOrderProcessFailed,
13
22
  onPaymentProcessFailed,
14
23
  onCompleted,
@@ -35,7 +44,10 @@ async function listenToProcessingState({
35
44
  window.parent.postMessage(messageCode, "*");
36
45
  }
37
46
 
38
- let orderCompleted = orderState === "complete";
47
+ // A pre-auth order completes on the hold, before the money is captured, so it isn't a success yet:
48
+ // the server sends these capture codes until PaymentCapturerJob reports order_is_completed.
49
+ let orderCompleted =
50
+ orderState === "complete" && !CAPTURE_PENDING_CODES.includes(messageCode);
39
51
  if (orderCompleted) {
40
52
  queueProcessor.queueStateChange({
41
53
  minDelayInMs: 1500,
@@ -108,6 +120,49 @@ async function listenToProcessingState({
108
120
  },
109
121
  });
110
122
  break;
123
+ case "payment_is_capturing":
124
+ queueProcessor.queueStateChange({
125
+ minDelayInMs: 1500,
126
+ callback: async () => {
127
+ // Optional: pages built before this message existed don't pass it.
128
+ await onPaymentIsCapturing?.(
129
+ orderState,
130
+ paymentState,
131
+ processing,
132
+ reasonCode,
133
+ reasonMessage,
134
+ );
135
+ },
136
+ });
137
+ break;
138
+ case "payment_capture_retrying":
139
+ queueProcessor.queueStateChange({
140
+ minDelayInMs: 1500,
141
+ callback: async () => {
142
+ await onPaymentCaptureRetrying?.(
143
+ orderState,
144
+ paymentState,
145
+ processing,
146
+ reasonCode,
147
+ reasonMessage,
148
+ );
149
+ },
150
+ });
151
+ break;
152
+ case "payment_capture_unconfirmed":
153
+ queueProcessor.queueStateChange({
154
+ minDelayInMs: 1500,
155
+ callback: async () => {
156
+ await onPaymentCaptureUnconfirmed?.(
157
+ orderState,
158
+ paymentState,
159
+ processing,
160
+ reasonCode,
161
+ reasonMessage,
162
+ );
163
+ },
164
+ });
165
+ break;
111
166
  case "order_process_failed":
112
167
  queueProcessor.queueStateChange({
113
168
  minDelayInMs: 1500,
@@ -0,0 +1,46 @@
1
+ module Vpago
2
+ # Callbacks for a job retried by Sidekiq itself. ActiveJob's `retry_on` isn't an option for a job with an
3
+ # `until_executed` lock: each retry is a new enqueue, which the job's own lock refuses.
4
+ #
5
+ # after_failed_attempt Spree::Core::GatewayError, with: :notify_retrying # each failed run; the error is re-raised
6
+ # after_retries_exhausted with: :notify_gave_up # once Sidekiq stops retrying
7
+ #
8
+ # Handlers are private instance methods that read the job's `arguments`. A handler error is logged and never
9
+ # replaces the job's own error, so Sidekiq still retries on the original failure.
10
+ module SidekiqRetryCallbacks
11
+ extend ActiveSupport::Concern
12
+
13
+ class_methods do
14
+ def after_failed_attempt(*errors, with:)
15
+ around_perform do |job, block|
16
+ block.call
17
+ rescue *errors => e
18
+ job.send(:run_retry_callback, with)
19
+ raise e
20
+ end
21
+ end
22
+
23
+ # Sidekiq-only, so a no-op where the host app doesn't run Sidekiq (e.g. this gem's specs). Sidekiq passes
24
+ # its own payload, which wraps the serialized ActiveJob.
25
+ def after_retries_exhausted(with:)
26
+ return unless respond_to?(:sidekiq_retries_exhausted)
27
+
28
+ sidekiq_retries_exhausted do |sidekiq_job, _error|
29
+ serialized_job = sidekiq_job['args'].first
30
+ new(*ActiveJob::Arguments.deserialize(serialized_job['arguments'])).send(:run_retry_callback, with)
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def run_retry_callback(method_name)
38
+ send(method_name)
39
+ rescue StandardError => e
40
+ VpagoLogger.error(
41
+ label: "#{self.class.name}##{method_name} failed",
42
+ data: { arguments: arguments, error_class: e.class.name, error_message: e.message }
43
+ )
44
+ end
45
+ end
46
+ end
@@ -1,11 +1,60 @@
1
1
  # Put :payment_processing at a higher priority in your project: config/sidekiq.yml
2
2
  module Vpago
3
3
  class PaymentCapturerJob < ::ApplicationUniqueJob
4
+ include Vpago::SidekiqRetryCallbacks
5
+
4
6
  queue_as :payment_processing
5
7
 
8
+ # A failed capture raises, so Sidekiq retries it. Pinned here rather than left to the host app's
9
+ # max_retries, because the count sets how long the processing page says "taking a little longer"
10
+ # before "not confirmed": 5 retries run for ~8 minutes with Sidekiq's default backoff.
11
+ sidekiq_options retry: 5 if respond_to?(:sidekiq_options)
12
+
13
+ # The default 1-day lock outlives a job that exhausted its retries and would silently refuse every
14
+ # re-enqueue for this payment (ABA pushback, a repair run) until the next day; 30 minutes covers the
15
+ # retry window above. The host app's ApplicationUniqueJob provides `unique`, spree_vpago doesn't.
16
+ unique :until_executed, lock_ttl: 30.minutes, on_conflict: :log if respond_to?(:unique)
17
+
18
+ # Keeps the processing page honest while Sidekiq retries a pre-auth capture.
19
+ after_failed_attempt Spree::Core::GatewayError, with: :notify_capture_retrying
20
+ after_retries_exhausted with: :give_up_capture
21
+
6
22
  def perform(payment_id)
7
- payment = Spree::Payment.find(payment_id)
23
+ @payment = Spree::Payment.find(payment_id)
8
24
  payment.capture! if payment.actions.include?('capture')
25
+
26
+ # The processing page holds a pre-auth payment on "verifying" until this message arrives (see
27
+ # PaymentProcessor#notify_order_completed). Keyed on the result rather than on this run's capture,
28
+ # so a retry after a failed Firestore write still sends it.
29
+ payment.user_informer.order_is_completed(processing: false) if payment.payment_method.enable_pre_auth? && payment.completed?
30
+ end
31
+
32
+ private
33
+
34
+ def notify_capture_retrying
35
+ return unless payment&.awaiting_pre_auth_capture?
36
+
37
+ payment.user_informer.payment_capture_retrying(processing: true)
38
+ end
39
+
40
+ # Frees the lock now rather than at its TTL, so the page's reload (or ABA pushback) can start a new
41
+ # capture straight away. Released after the page is told, so a reload racing this can't have its
42
+ # "verifying" overwritten by "not confirmed".
43
+ def give_up_capture
44
+ notify_capture_unconfirmed
45
+ ensure
46
+ self.class.unlock!(*arguments) if self.class.respond_to?(:unlock!)
47
+ end
48
+
49
+ # Skipped when the capture itself succeeded and only the later Firestore write kept failing.
50
+ def notify_capture_unconfirmed
51
+ return unless payment&.awaiting_pre_auth_capture?
52
+
53
+ payment.user_informer.payment_capture_unconfirmed(processing: false)
54
+ end
55
+
56
+ def payment
57
+ @payment ||= Spree::Payment.find_by(id: arguments.first)
9
58
  end
10
59
  end
11
60
  end
@@ -12,6 +12,8 @@
12
12
  #
13
13
  # This allows process! or capture! to be retried by Sidekiq or by admin via /fire after connection errors
14
14
  # or other transient gateway failures.
15
+ #
16
+ # Pre-auth capture! is the exception: it never leaves `pending` on failure, see #pre_auth_capture!.
15
17
  module Vpago
16
18
  module PaymentDecorator
17
19
  def self.prepended(base)
@@ -55,6 +57,8 @@ module Vpago
55
57
 
56
58
  # override
57
59
  def capture!(amount = nil)
60
+ return pre_auth_capture!(amount) if payment_method&.enable_pre_auth?
61
+
58
62
  reset_for_retry! if failed?
59
63
 
60
64
  super
@@ -97,6 +101,12 @@ module Vpago
97
101
  pre_auth_status == 'CANCELLED'
98
102
  end
99
103
 
104
+ # Authorized (ABA holds the money) but not captured yet: the order may already be complete,
105
+ # yet nothing has been charged.
106
+ def awaiting_pre_auth_capture?
107
+ payment_method.enable_pre_auth? && (pending? || processing?)
108
+ end
109
+
100
110
  def true_money_payment?
101
111
  payment_method.type_true_money?
102
112
  end
@@ -124,6 +134,42 @@ module Vpago
124
134
  def can_redirect_to_app?
125
135
  payment_method.can_redirect_to_app?
126
136
  end
137
+
138
+ private
139
+
140
+ # Spree's capture! moves the payment to processing and, when the gateway fails, to failed.
141
+ # For a pre-auth payment that is wrong: ABA still holds the money, and `failed` hides the
142
+ # capture/void actions (VpagoPaymentSource requires `pending`), so PaymentCapturerJob's
143
+ # Sidekiq retries found nothing to do and the capture was attempted only once.
144
+ #
145
+ # Here only a confirmed capture changes the state (pending -> completed). A failed attempt
146
+ # writes nothing, so the payment stays retryable and a slow failing call can't overwrite a
147
+ # concurrent success. The capture event is also written only when money actually moved
148
+ # (Spree writes it before knowing the result).
149
+ def pre_auth_capture!(amount)
150
+ return true if completed?
151
+
152
+ # A `failed` pre-auth payment was left by the old flow; its hold may or may not still be
153
+ # active, so it must be checked with ABA and repaired before capturing again.
154
+ gateway_error("Cannot capture pre-auth payment #{number} in state #{state}") unless pending? || processing?
155
+
156
+ amount ||= money.amount_in_cents
157
+ response = begin
158
+ payment_method.capture(amount, response_code, gateway_options)
159
+ rescue ActiveMerchant::ConnectionError, Faraday::Error => e
160
+ # Spree logs nothing for a connection error; record it so the payment's log shows every attempt.
161
+ record_response(ActiveMerchant::Billing::Response.new(false, "Payway Gateway: #{e.class}: #{e.message}", {}))
162
+ gateway_error(ActiveMerchant::ConnectionError.new(e.message, e))
163
+ end
164
+
165
+ record_response(response)
166
+ gateway_error(response) unless response.success?
167
+
168
+ capture_events.create!(amount: ::Money.new(amount, currency).to_f)
169
+ split_uncaptured_amount
170
+ self.response_code = response.authorization if response.authorization.present?
171
+ complete!
172
+ end
127
173
  end
128
174
  end
129
175
 
@@ -49,7 +49,9 @@ module Vpago
49
49
  # which will enqueue a capture payment if available.
50
50
  def process_order!
51
51
  log_process('process_order!') do
52
- user_informer.order_is_processing(processing: true)
52
+ # On a re-run for a completed pre-auth order (ABA pushback, page reload) this message would carry
53
+ # order_state=complete, which the processing page treats as success before the money is captured.
54
+ user_informer.order_is_processing(processing: true) unless awaiting_pre_auth_capture?
53
55
  completer = Spree::Checkout::Complete.new.call(order: @payment.order)
54
56
 
55
57
  if completer.success?
@@ -63,11 +65,27 @@ module Vpago
63
65
 
64
66
  def handle_order_process_completed
65
67
  log_process('handle_order_process_completed') do
68
+ # Notify before enqueueing: PaymentCapturerJob reports order_is_completed when the capture lands,
69
+ # and that message must not be overwritten by a later payment_is_capturing.
70
+ notify_order_completed
66
71
  enqueue_capture_payment_if_available!
72
+ end
73
+ end
74
+
75
+ # A pre-auth order completes on the hold, but the money only moves at capture, so the processing
76
+ # page keeps showing "verifying" until PaymentCapturerJob sends order_is_completed.
77
+ def notify_order_completed
78
+ if awaiting_pre_auth_capture?
79
+ user_informer.payment_is_capturing(processing: true)
80
+ else
67
81
  user_informer.order_is_completed(processing: false)
68
82
  end
69
83
  end
70
84
 
85
+ def awaiting_pre_auth_capture?
86
+ @payment.reload.awaiting_pre_auth_capture? && @payment.order.complete?
87
+ end
88
+
71
89
  def handle_order_process_failure(reason_code, reason_message = nil)
72
90
  log_process('handle_order_process_failure', reason_code, reason_message) do
73
91
  user_informer.order_process_failed(
@@ -13,6 +13,9 @@ module Vpago
13
13
  def payment_is_retrying(processing:) = notify(:payment_is_retrying, processing)
14
14
  def order_is_processing(processing:) = notify(:order_is_processing, processing)
15
15
  def order_is_completed(processing:) = notify(:order_is_completed, processing)
16
+ def payment_is_capturing(processing:) = notify(:payment_is_capturing, processing)
17
+ def payment_capture_retrying(processing:) = notify(:payment_capture_retrying, processing)
18
+ def payment_capture_unconfirmed(processing:) = notify(:payment_capture_unconfirmed, processing)
16
19
  def order_process_failed(processing:, reason_code:, reason_message: nil) = notify(:order_process_failed, processing, reason_code, reason_message)
17
20
  def payment_process_failed(processing:, reason_code:, reason_message: nil) = notify(:payment_process_failed, processing, reason_code, reason_message)
18
21
 
@@ -1,7 +1,7 @@
1
1
  module SpreeVpago
2
2
  module_function
3
3
 
4
- VERSION = '2.4.2'.freeze
4
+ VERSION = '2.4.3'.freeze
5
5
 
6
6
  def version
7
7
  Gem::Version.new VERSION
@@ -5,11 +5,7 @@ module Vpago
5
5
  class PreAuthCanceler < PreAuthCompleter
6
6
  # override
7
7
  def pre_auth_response
8
- conn = Faraday::Connection.new do |faraday|
9
- faraday.request :url_encoded
10
- end
11
-
12
- conn.post(cancel_url, request_data)
8
+ connection.post(cancel_url, request_data)
13
9
  end
14
10
 
15
11
  def request_data
@@ -18,11 +18,17 @@ module Vpago
18
18
  end
19
19
 
20
20
  def pre_auth_response
21
- conn = Faraday::Connection.new do |faraday|
21
+ connection.post(complete_url, request_data)
22
+ end
23
+
24
+ # Without timeouts Net::HTTP waits up to 60s to connect and 60s to read, holding the
25
+ # payment_processing worker thread that also completes every other customer's checkout.
26
+ def connection
27
+ Faraday::Connection.new do |faraday|
22
28
  faraday.request :url_encoded
29
+ faraday.options.open_timeout = Vpago::HttpTimeouts::OPEN_TIMEOUT
30
+ faraday.options.timeout = Vpago::HttpTimeouts::TIMEOUT
23
31
  end
24
-
25
- conn.post(complete_url, request_data)
26
32
  end
27
33
 
28
34
  def request_data
@@ -61,8 +67,11 @@ module Vpago
61
67
  "#{host}/api/merchant-portal/merchant-access/online-transaction/pre-auth-completion"
62
68
  end
63
69
 
70
+ # A non-JSON body (e.g. an HTML 502 page) must be a normal failure. Raising here would skip
71
+ # Spree's failure handling and leave the payment without any log entry.
64
72
  def success?
65
- json_response['status']['code'] == '00'
73
+ status = json_response.is_a?(Hash) ? json_response['status'] : nil
74
+ status.is_a?(Hash) && status['code'].to_s == '00'
66
75
  end
67
76
 
68
77
  def json_response
@@ -71,7 +80,8 @@ module Vpago
71
80
  @json_response ||= begin
72
81
  JSON.parse(@response.body)
73
82
  rescue JSON::ParserError
74
- {}
83
+ # Kept so the payment's log entry shows what the gateway actually returned.
84
+ { 'raw_body' => @response.body.to_s[0, 2000], 'http_status' => @response.status }
75
85
  end
76
86
  end
77
87
  end