spree_paypal_checkout 0.7.0 → 6.0.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +16 -4
  3. data/README.md +34 -52
  4. data/Rakefile +7 -17
  5. data/app/jobs/spree_paypal_checkout/base_job.rb +5 -0
  6. data/app/jobs/spree_paypal_checkout/register_webhook_job.rb +20 -0
  7. data/app/maintenance_tasks/spree_paypal_checkout/maintenance_tasks/migrate_payments.rb +100 -0
  8. data/app/models/spree/payment_sessions/paypal_checkout.rb +39 -57
  9. data/app/models/spree_paypal_checkout/amounts.rb +55 -0
  10. data/app/models/spree_paypal_checkout/gateway/payment_sessions.rb +232 -149
  11. data/app/models/spree_paypal_checkout/gateway/webhooks.rb +112 -0
  12. data/app/models/spree_paypal_checkout/gateway.rb +418 -128
  13. data/app/models/spree_paypal_checkout/notifications_client.rb +82 -0
  14. data/app/models/spree_paypal_checkout/payment_sources/paypal.rb +43 -3
  15. data/app/presenters/spree_paypal_checkout/order_presenter.rb +121 -49
  16. data/app/services/spree_paypal_checkout/register_webhook.rb +30 -0
  17. data/config/locales/en.yml +23 -4
  18. data/lib/generators/spree_paypal_checkout/install/install_generator.rb +12 -12
  19. data/lib/spree_paypal_checkout/engine.rb +6 -37
  20. data/lib/spree_paypal_checkout/factories.rb +1 -48
  21. data/lib/spree_paypal_checkout/testing_support/factories/gateway_factory.rb +18 -0
  22. data/lib/spree_paypal_checkout/testing_support/factories/payment_session_factory.rb +16 -0
  23. data/lib/spree_paypal_checkout.rb +11 -3
  24. data/lib/tasks/register_paypal_webhooks.rake +37 -0
  25. metadata +33 -101
  26. data/app/assets/config/spree_paypal_checkout_manifest.js +0 -2
  27. data/app/controllers/spree_paypal_checkout/store_controller_decorator.rb +0 -9
  28. data/app/helpers/spree_paypal_checkout/base_helper.rb +0 -11
  29. data/app/javascript/spree_paypal_checkout/application.js +0 -16
  30. data/app/javascript/spree_paypal_checkout/controllers/checkout_paypal_controller.js +0 -117
  31. data/app/models/spree_paypal_checkout/base.rb +0 -6
  32. data/app/models/spree_paypal_checkout/order.rb +0 -71
  33. data/app/models/spree_paypal_checkout/order_decorator.rb +0 -11
  34. data/app/models/spree_paypal_checkout/payment_method_decorator.rb +0 -15
  35. data/app/models/spree_paypal_checkout/store_decorator.rb +0 -9
  36. data/app/services/spree_paypal_checkout/capture_order.rb +0 -104
  37. data/app/services/spree_paypal_checkout/create_payment.rb +0 -34
  38. data/app/services/spree_paypal_checkout/create_source.rb +0 -52
  39. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_paypal_checkout.html.erb +0 -6
  40. data/app/views/spree/admin/payment_methods/descriptions/_spree_paypal_checkout.html.erb +0 -10
  41. data/app/views/spree/admin/payments/source_forms/_spree_paypal_checkout.html.erb +0 -7
  42. data/app/views/spree/checkout/payment/_spree_paypal_checkout.html.erb +0 -13
  43. data/app/views/spree/payment_sources/_paypal_checkout.html.erb +0 -16
  44. data/app/views/spree_paypal_checkout/_head.html.erb +0 -11
  45. data/config/importmap.rb +0 -6
  46. data/config/initializers/spree.rb +0 -7
  47. data/config/routes.rb +0 -16
  48. data/db/migrate/20250528095719_create_spree_paypal_checkout_orders.rb +0 -20
  49. data/lib/spree_api_v2/spree/api/v2/storefront/paypal_order_serializer.rb +0 -11
  50. data/lib/spree_api_v2/spree/api/v2/storefront/paypal_orders_controller.rb +0 -55
  51. data/lib/spree_paypal_checkout/configuration.rb +0 -5
  52. data/lib/spree_paypal_checkout/version.rb +0 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ef8dfb2e6f13c0334979a7af8f62d21882bdb0e2a717e36328aec335194d8767
4
- data.tar.gz: d2f23cb259fb7f2c55b3c4af5fc98d48e9625f46ec239a351f9e6896f002302e
3
+ metadata.gz: b41da8ff17667c478aa2a55cc31033ba2e6478cde897ee20afa80f35aec99ae1
4
+ data.tar.gz: 25feabb516457d5699b497c8bdadff0a3221c66fd7643f871ad872de872ed0e8
5
5
  SHA512:
6
- metadata.gz: 2bca77d66f83220e40a00cb7edbba2560948384c46a4033ac2b3e1dd92d7e08235f366f3f57c867e1a790539e3e48d521ca61fd1899f9283dd5ad9105c1ed4b2
7
- data.tar.gz: ec63da1f24b8a6885f8da03d4a7fcbd212eb5cfba45d1f42922dffe167549fd84625b97ab2349820d8bc484c5cf97b5423b842044dc34e5819f6d5e2815cff0d
6
+ metadata.gz: a601b77f4a2f7cb3b0a78bc8d1dc619d048404ca706e150a1699f7fc78f8d5e25d20b75c4368c8d983f3611b9d6f3feab0ed43e25587450f40caf396b7899d45
7
+ data.tar.gz: 80cdce32765ca01bb72e1e50ccafdfae1d843e06a1b8c9f0a88b3d05750dc807744f7e9716366b273af4e9668625474c020b44ede67dc95b140c072364147a6f
data/LICENSE CHANGED
@@ -1,9 +1,21 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Vendo Connect Inc.
3
+ Copyright (c) 2026-present, Vendo Sp. z o.o., Vendo Connect Inc.
4
4
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
6
11
 
7
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
8
14
 
9
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,69 +1,51 @@
1
1
  # Spree PayPal Checkout
2
2
 
3
- This is the official PayPal Checkout extension for [Spree Commerce](https://spreecommerce.org).
3
+ Official [PayPal](https://www.paypal.com) payment gateway for
4
+ [Spree Commerce](https://spreecommerce.org), built on Spree's payment session
5
+ API.
4
6
 
5
- This PayPal Checkout integration is bundled in the [Spree Starter](https://github.com/spree/spree_starter/) for your development convenience.
6
-
7
- Or you could follow the [installation instructions](https://spreecommerce.org/docs/integrations/payments/paypal).
8
-
9
- If you like what you see, consider giving this repo a GitHub star :star:
10
-
11
- Thank you for supporting Spree open-source :heart:
7
+ Supports PayPal, Pay Later, Venmo, and card payments that buyers without a
8
+ PayPal account make in PayPal's own window. Honors the store's capture setting:
9
+ payments are captured at checkout, or authorized and captured later.
12
10
 
13
11
  ## Installation
14
12
 
15
- 1. Add this extension to your Gemfile with this line:
16
-
17
- ```ruby
18
- bundle add spree_paypal_checkout
19
- ```
20
-
21
- 2. Run the install generator
22
-
23
- ```ruby
24
- bundle exec rails g spree_paypal_checkout:install
25
- ```
26
-
27
- 3. Restart your server
28
-
29
- If your server was running, restart it so that it can find the assets properly.
13
+ ```bash
14
+ spree add paypal
15
+ ```
30
16
 
31
- ## Developing
17
+ Without the Spree CLI, add `gem 'spree_paypal_checkout'` to the `Gemfile` with
18
+ the same version requirement as your `spree` gem and run `bundle install`. The
19
+ gateway registers itself — there is nothing to generate and no migrations to
20
+ run.
32
21
 
33
- 1. Create a dummy app
22
+ ## Configuration
34
23
 
35
- ```bash
36
- bundle update
37
- bundle exec rake test_app
38
- ```
24
+ Create a PayPal payment method in the admin dashboard and enter the client id
25
+ and secret of your PayPal REST app. Leave Test mode on for sandbox credentials
26
+ and turn it off for live ones. Saving queues a job that registers the webhook
27
+ with PayPal; until it has run, PayPal's notifications are refused, so if none
28
+ arrive, check the job queue for a failed `SpreePaypalCheckout::RegisterWebhookJob`.
39
29
 
40
- 2. Add your new code
41
- 3. Run tests
30
+ Set `PAYPAL_LOG_LEVEL` (for example `debug`) to change how much the PayPal
31
+ client logs. Request and response bodies are never logged.
42
32
 
43
- ```bash
44
- bundle exec rspec
45
- ```
33
+ ## Upgrading from spree_paypal_checkout 0.x
46
34
 
47
- When testing your applications integration with this extension you may use it's factories.
48
- Simply add this require statement to your spec_helper:
35
+ See the PayPal section of the Spree 5.6 → 6.0 upgrade guide. Two upgrade
36
+ steps, `migrate_paypal_payments` and `register_paypal_webhooks`, carry existing
37
+ payments and webhooks over; run them before dropping the old
38
+ `spree_paypal_checkout_orders` table.
49
39
 
50
- ```ruby
51
- require 'spree_paypal_checkout/factories'
52
- ```
40
+ ## Testing
53
41
 
54
- ## Releasing a new version
42
+ The suite runs offline against recorded VCR cassettes:
55
43
 
56
- ```shell
57
- bundle exec gem bump -p -t
58
- bundle exec gem release
44
+ ```bash
45
+ bundle exec rake test_app # once
46
+ bundle exec rspec
59
47
  ```
60
48
 
61
- For more options please see [gem-release README](https://github.com/svenfuchs/gem-release)
62
-
63
- ## Contributing
64
-
65
- If you'd like to contribute, please take a look at the
66
- [instructions](CONTRIBUTING.md) for installing dependencies and crafting a good
67
- pull request.
68
-
69
- Copyright (c) 2025 Vendo Connect Inc, released under the AGPL-3.0 license
49
+ Record cassettes against the PayPal sandbox by adding `RECORD_VCR=1`,
50
+ `PAYPAL_CLIENT_ID` and `PAYPAL_CLIENT_SECRET` to the environment and running the
51
+ `vcr`-tagged examples.
data/Rakefile CHANGED
@@ -1,25 +1,15 @@
1
- require 'bundler'
2
- Bundler::GemHelper.install_tasks
3
-
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
4
  require 'rspec/core/rake_task'
5
- require 'spree/testing_support/extension_rake'
5
+ require 'spree/testing_support/common_rake'
6
6
 
7
7
  RSpec::Core::RakeTask.new
8
8
 
9
- task :default do
10
- if Dir['spec/dummy'].empty?
11
- Rake::Task[:test_app].invoke
12
- Dir.chdir('../../')
13
- end
14
- Rake::Task[:spec].invoke
15
- end
9
+ task default: :spec
16
10
 
17
- desc 'Generates a dummy app for testing'
11
+ desc "Generates a dummy app for testing"
18
12
  task :test_app do
19
13
  ENV['LIB_NAME'] = 'spree_paypal_checkout'
20
- Rake::Task['extension:test_app'].execute(
21
- install_storefront: true,
22
- install_admin: true
23
- )
24
- system({ 'BUNDLE_GEMFILE' => File.expand_path('Gemfile', __dir__) }, 'rails g spree_legacy_api_v2:install')
14
+ Rake::Task['common:test_app'].invoke
25
15
  end
@@ -0,0 +1,5 @@
1
+ module SpreePaypalCheckout
2
+ class BaseJob < Spree::BaseJob
3
+ queue_as { SpreePaypalCheckout.queue }
4
+ end
5
+ end
@@ -0,0 +1,20 @@
1
+ module SpreePaypalCheckout
2
+ class RegisterWebhookJob < BaseJob
3
+ # Safe to replay: registration finds the webhook for the URL before
4
+ # creating one.
5
+ retry_on StandardError, wait: :polynomially_longer, attempts: 5
6
+ # Must come after `retry_on StandardError` so DeserializationError lands in discard.
7
+ discard_on ActiveJob::DeserializationError
8
+
9
+ def perform(payment_method_id)
10
+ # Locked on load: two saves in a row queue two jobs, and both would
11
+ # otherwise create a webhook for the same URL.
12
+ Spree::PaymentMethod.transaction do
13
+ payment_method = Spree::PaymentMethod.lock.find_by(id: payment_method_id)
14
+ next unless payment_method.is_a?(SpreePaypalCheckout::Gateway)
15
+
16
+ payment_method.register_webhook
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,100 @@
1
+ module SpreePaypalCheckout
2
+ module MaintenanceTasks
3
+ # Gives PayPal payments taken under the 5.x gem the 6.0 shape: the PayPal
4
+ # order id as the response code, the key core links a session to its
5
+ # payment by, and the capture id the 5.x gem stored there moved to
6
+ # metadata['paypal_capture_id'], where refunds and the dashboard link read
7
+ # it.
8
+ #
9
+ # The order id is found two ways, both from the 5.x gem's own records: the
10
+ # session that took the payment holds the capture in its external data, and
11
+ # a payment from the older non-session flow is matched through
12
+ # spree_paypal_checkout_orders, which must therefore not be dropped first.
13
+ # A payment matched by neither is logged and left as it was.
14
+ class MigratePayments < Spree::MaintenanceTask
15
+ LEGACY_ORDERS_TABLE = 'spree_paypal_checkout_orders'.freeze
16
+
17
+ supports_dry_run
18
+
19
+ def collection
20
+ gateway_ids = Spree::PaymentMethod.unscoped.where(type: 'SpreePaypalCheckout::Gateway').select(:id)
21
+ Spree::Payment.unscoped.where(payment_method_id: gateway_ids).where.not(response_code: [nil, '']).order(:id)
22
+ end
23
+
24
+ def process(payment)
25
+ # Payments taken in 6.0, and those an earlier run converted, already
26
+ # carry what PayPal issued.
27
+ if payment.metadata['paypal_capture_id'].present? || payment.metadata['paypal_authorization_id'].present?
28
+ return tally(:already_converted)
29
+ end
30
+
31
+ paypal_order_id = paypal_order_id_for(payment)
32
+ if paypal_order_id.nil?
33
+ Rails.logger.warn("[spree_paypal_checkout] migrate_paypal_payments: no PayPal order found for payment #{payment.id}")
34
+ return tally(:unmatched)
35
+ end
36
+ return tally(:would_convert) if dry_run?
37
+
38
+ payment.update_columns(
39
+ response_code: paypal_order_id,
40
+ metadata: payment.metadata.merge('paypal_capture_id' => payment.response_code),
41
+ updated_at: Time.current
42
+ )
43
+ tally(:converted)
44
+ rescue ActiveRecord::RecordNotUnique
45
+ # Another payment of the same order already carries this PayPal order.
46
+ Rails.logger.warn("[spree_paypal_checkout] migrate_paypal_payments: payment #{payment.id} duplicates another payment's PayPal order")
47
+ tally(:unmatched)
48
+ end
49
+
50
+ private
51
+
52
+ def paypal_order_id_for(payment)
53
+ capture_id = payment.response_code
54
+ session_order_id(payment, capture_id) || legacy_order_id(payment, capture_id)
55
+ end
56
+
57
+ # The 5.x session stored PayPal's whole capture response as its external
58
+ # data, the order id as its external id.
59
+ # Looked up within the payment's own order or cart, so each payment reads
60
+ # a handful of rows.
61
+ def session_order_id(payment, capture_id)
62
+ owner = payment.order_id ? { order_id: payment.order_id } : { cart_id: payment.cart_id }
63
+ return if owner.values.first.nil?
64
+
65
+ sessions = Spree::PaymentSessions::PaypalCheckout.unscoped.where(payment_method_id: payment.payment_method_id, **owner)
66
+ sessions.find { |session| capture_id_in(session.external_data) == capture_id }&.external_id
67
+ end
68
+
69
+ # The older flow only ever paid for orders.
70
+ def legacy_order_id(payment, capture_id)
71
+ return if payment.order_id.nil? || !legacy_orders_table?
72
+
73
+ rows = legacy_order_model.where(payment_method_id: payment.payment_method_id, order_id: payment.order_id)
74
+ rows.find { |row| capture_id_in(row.data) == capture_id }&.paypal_id
75
+ end
76
+
77
+ def capture_id_in(data)
78
+ data = JSON.parse(data) if data.is_a?(String)
79
+ return unless data.is_a?(Hash)
80
+
81
+ data.dig('purchase_units', 0, 'payments', 'captures', 0, 'id')
82
+ rescue JSON::ParserError
83
+ nil
84
+ end
85
+
86
+ def legacy_orders_table?
87
+ @legacy_orders_table = ActiveRecord::Base.connection.table_exists?(LEGACY_ORDERS_TABLE) if @legacy_orders_table.nil?
88
+ @legacy_orders_table
89
+ end
90
+
91
+ # The 5.x model is gone, so the table is read through a throwaway one.
92
+ def legacy_order_model
93
+ @legacy_order_model ||= Class.new(ActiveRecord::Base) do
94
+ self.table_name = LEGACY_ORDERS_TABLE
95
+ self.inheritance_column = nil
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,76 +1,58 @@
1
1
  module Spree
2
+ # Stored under this name in `type` columns since 5.x, so it is kept.
2
3
  class PaymentSessions::PaypalCheckout < PaymentSession
3
- def paypal_order_id
4
- external_id
5
- end
4
+ # The order read at completion, or by prepare_for_settlement! on the
5
+ # webhook path, so settlement inside the lock does no PayPal I/O.
6
+ attr_writer :paypal_order
6
7
 
7
- def paypal_capture_id
8
- external_data&.dig('purchase_units', 0, 'payments', 'captures', 0, 'id')
8
+ def intent
9
+ external_data&.dig('intent')
9
10
  end
10
11
 
11
- def paypal_payer
12
- external_data&.dig('payer')
12
+ # Moves only after a recoverable decline, so a buyer's next try reaches
13
+ # PayPal as a new request rather than a replay of the declined one.
14
+ #
15
+ # @return [Integer]
16
+ def attempt
17
+ (external_data&.dig('attempt') || 1).to_i
13
18
  end
14
19
 
15
- def paypal_payment_source
16
- external_data&.dig('payment_source')
20
+ # Whether Complete ever asked PayPal to move money for this session.
21
+ def complete_attempted?
22
+ external_data.present? && (external_data.key?('attempt') || external_data.key?('paypal_result'))
17
23
  end
18
24
 
19
- def accepted?
20
- external_data&.dig('status') == 'COMPLETED'
25
+ # @return [Hash]
26
+ def paypal_order
27
+ @paypal_order ||= payment_method.paypal_order(external_id)
21
28
  end
22
29
 
23
- def successful?
24
- accepted?
30
+ # Settlement reads the order only to create the payment, so a session that
31
+ # already has one needs no PayPal call.
32
+ def prepare_for_settlement!
33
+ paypal_order if payment.nil?
34
+ self
25
35
  end
26
36
 
27
- # Creates or finds the Spree::Payment for this session.
28
- # Defers creation until paypal_capture_id is present so response_code
29
- # is always the capture ID (required for refunds via Gateway#credit).
30
- def find_or_create_payment!(metadata = {})
31
- return unless persisted?
32
- return payment if payment.present?
33
- return unless paypal_capture_id
34
-
35
- order.with_lock do
36
- existing_payment = order.payments.where(
37
- payment_method: payment_method,
38
- response_code: paypal_capture_id
39
- ).first
40
-
41
- return existing_payment if existing_payment.present?
42
-
43
- source = create_payment_source!
44
-
45
- order.payments.create!(
46
- payment_method: payment_method,
47
- amount: amount,
48
- response_code: paypal_capture_id,
49
- source: source,
50
- skip_source_requirement: true,
51
- private_metadata: metadata
52
- )
53
- end
37
+ # @return [SpreePaypalCheckout::PaymentSources::Paypal, nil]
38
+ def payment_source_for_settlement
39
+ SpreePaypalCheckout::PaymentSources::Paypal.for_payment_source(
40
+ paypal_order['payment_source'],
41
+ payment_method: payment_method,
42
+ customer: owner&.customer,
43
+ paypal_order_id: external_id
44
+ )
54
45
  end
55
46
 
56
- private
57
-
58
- def create_payment_source!
59
- paypal_data = paypal_payment_source&.dig('paypal')
60
- return nil unless paypal_data
47
+ # Records what PayPal issued against the order, which refunds and the
48
+ # dashboard link cannot read from the order id alone.
49
+ def apply_settlement_metadata(payment, metadata)
50
+ super
61
51
 
62
- source = SpreePaypalCheckout::PaymentSources::Paypal.find_or_initialize_by(
63
- payment_method: payment_method,
64
- gateway_payment_profile_id: paypal_data['account_id']
65
- )
66
- source.update!(
67
- user: order.user,
68
- email: paypal_data['email_address'],
69
- name: "#{paypal_data.dig('name', 'given_name')} #{paypal_data.dig('name', 'surname')}".strip,
70
- account_id: paypal_data['account_id'],
71
- account_status: paypal_data['account_status']
72
- )
73
- source
52
+ capture = payment_method.captures(paypal_order).first
53
+ authorization = payment_method.authorizations(paypal_order).first
54
+ payment.metadata['paypal_capture_id'] = capture['id'] if capture
55
+ payment.metadata['paypal_authorization_id'] = authorization['id'] if authorization
74
56
  end
75
57
  end
76
58
  end
@@ -0,0 +1,55 @@
1
+ module SpreePaypalCheckout
2
+ # PayPal's decimal-string amounts (https://developer.paypal.com/reference/currency-codes/).
3
+ # Written in the currency's ISO exponent, except that PayPal takes HUF, JPY
4
+ # and TWD only in whole units. An amount PayPal would refuse raises here,
5
+ # naming the rule, rather than being rounded to a sum Spree did not record.
6
+ module Amounts
7
+ module_function
8
+
9
+ SUPPORTED_CURRENCIES = %w[
10
+ AUD BRL CAD CHF CNY CZK DKK EUR GBP HKD HUF ILS JPY MXN MYR NOK NZD PHP PLN SEK SGD THB TWD USD
11
+ ].freeze
12
+ WHOLE_UNITS = %w[HUF JPY TWD].freeze
13
+
14
+ class UnsupportedAmount < Spree::Core::GatewayError; end
15
+
16
+ # @param currency [String]
17
+ # @return [Boolean]
18
+ def supported_currency?(currency)
19
+ SUPPORTED_CURRENCIES.include?(currency.to_s.upcase)
20
+ end
21
+
22
+ # @return [Boolean] whether PayPal takes this amount in this currency
23
+ def payable?(amount, currency)
24
+ to_paypal(amount, currency)
25
+ true
26
+ rescue UnsupportedAmount
27
+ false
28
+ end
29
+
30
+ # @param amount [BigDecimal, Spree::Money]
31
+ # @param currency [String]
32
+ # @return [String] e.g. "19.99", or "2000" for yen
33
+ # @raise [UnsupportedAmount] when PayPal would refuse the amount
34
+ def to_paypal(amount, currency)
35
+ amount = amount.to_d if amount.respond_to?(:to_d)
36
+ code = currency.to_s.upcase
37
+ raise UnsupportedAmount, "PayPal does not take #{code}" unless supported_currency?(code)
38
+
39
+ places = WHOLE_UNITS.include?(code) ? 0 : Spree::Money::Rounding.precision(code)
40
+ quantized = Spree::Money::Rounding.quantize(amount, places)
41
+ if quantized != amount
42
+ rule = places.zero? ? 'only in whole units' : "only to #{places} decimal places"
43
+ raise UnsupportedAmount, "PayPal takes #{code} #{rule}, got #{amount.to_s('F')}"
44
+ end
45
+ return quantized.to_i.to_s if places.zero?
46
+
47
+ Spree::Money::Rounding.format(quantized, code)
48
+ end
49
+
50
+ # @return [PaypalServerSdk::Money]
51
+ def to_paypal_money(amount, currency)
52
+ PaypalServerSdk::Money.new(currency_code: currency.to_s.upcase, value: to_paypal(amount, currency))
53
+ end
54
+ end
55
+ end