spreedly_core 0.0.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.
- data/LICENSE +15 -0
- data/README.md +186 -0
- data/Rakefile +33 -0
- data/lib/spreedly_core/base.rb +93 -0
- data/lib/spreedly_core/gateway.rb +19 -0
- data/lib/spreedly_core/payment_method.rb +106 -0
- data/lib/spreedly_core/test_extensions.rb +47 -0
- data/lib/spreedly_core/transactions.rb +127 -0
- data/lib/spreedly_core/version.rb +3 -0
- data/lib/spreedly_core.rb +37 -0
- data/test/config/spreedly_core.yml +3 -0
- data/test/config/spreedly_core.yml.example +3 -0
- data/test/spreedly_core_test.rb +237 -0
- data/test/test_helper.rb +17 -0
- metadata +107 -0
data/LICENSE
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
Copyright (c) 2011 403 Labs, LLC <http://www.403labs.com>
|
2
|
+
|
3
|
+
SpreedlyCore library is free software: you can redistribute it and/or
|
4
|
+
modify it under the terms of the GNU Lesser General Public License as
|
5
|
+
published by the Free Software Foundation, either version 3 of the
|
6
|
+
License, or (at your option) any later version.
|
7
|
+
|
8
|
+
SpreedlyCore library is distributed in the hope that it will be useful,
|
9
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
10
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
|
11
|
+
General Public License for more details.
|
12
|
+
|
13
|
+
You should have received a copy of the GNU Lesser General Public License
|
14
|
+
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
15
|
+
|
data/README.md
ADDED
@@ -0,0 +1,186 @@
|
|
1
|
+
SpreedlyCore
|
2
|
+
======
|
3
|
+
|
4
|
+
SpreedlyCore is a Ruby library for accessing the [Spreedly Core API](https://spreedlycore.com/).
|
5
|
+
|
6
|
+
The beauty behind Spreedly Core is that you lower your
|
7
|
+
[PCI Compliance](https://www.pcisecuritystandards.org/) risk
|
8
|
+
by storing credit card information on their service while still having access
|
9
|
+
to make payments and credits. This is possible by having your customers POST their
|
10
|
+
credit card info to the spreedly core service while embedding a transparent
|
11
|
+
redirect URL back to your application. See "Submit payment form" on
|
12
|
+
[the quick start guide](https://spreedlycore.com/manual/quickstart)
|
13
|
+
how the magic happens.
|
14
|
+
|
15
|
+
|
16
|
+
Quickstart
|
17
|
+
----------
|
18
|
+
|
19
|
+
RubyGems:
|
20
|
+
|
21
|
+
gem install spreedly_core
|
22
|
+
irb
|
23
|
+
require 'rubygems'
|
24
|
+
require 'spreedly_core'
|
25
|
+
SpreedlyCore.configure("Your API Login", "Your API Secret", "Test Gateway Token")
|
26
|
+
See the [quickstart guide](https://spreedlycore.com/manual/quickstart) for
|
27
|
+
information regarding tokens.
|
28
|
+
|
29
|
+
We'll now lookup the payment method stored on SpreedlyCore using token param
|
30
|
+
from the transparent redirect url
|
31
|
+
|
32
|
+
payment_token = SpreedlyCore::PaymentMethod.find(payment_token)
|
33
|
+
transaction = payment_token.purchase(100)
|
34
|
+
|
35
|
+
Test Integration
|
36
|
+
----------
|
37
|
+
|
38
|
+
Since your web form handles the creation of payment methods on their service,
|
39
|
+
integration testing can be a bit of a headache. No worries though:
|
40
|
+
|
41
|
+
require 'spreedly_core'
|
42
|
+
require 'spreedly_core/test_extensions'
|
43
|
+
SpreedlyCore.configure("Your API Login", "Your API Secret", "Test Gateway Token")
|
44
|
+
master_card_data = SpreedlyCore::TestHelper.cc_data(:master) # Lookup test credit card data
|
45
|
+
token = SpreedlyCore::PaymentMethod.create_test_token(master_card_data)
|
46
|
+
|
47
|
+
You now have access to a payment method token, which can be used just like your
|
48
|
+
application would use it. Note, you should use a test gateway since you are
|
49
|
+
actually hitting the Spreedly Core service. Let's use the test credit card
|
50
|
+
payment method to make a purchase:
|
51
|
+
|
52
|
+
payment_method = SpreedlyCore::PaymentMethod.find(token)
|
53
|
+
purchase_transaction = payment_method.purchase(100)
|
54
|
+
purchase_transaction.succeeded? # true
|
55
|
+
|
56
|
+
Let's now use a credit card that is configured to fail upon purchase:
|
57
|
+
|
58
|
+
master_card_data = SpreedlyCore::TestHelper.cc_data(:master, :card_type => :failed)
|
59
|
+
token = SpreedlyCore::PaymentMethod.create_test_token(master_card_data)
|
60
|
+
payment_method = SpreedlyCore::PaymentMethod.find(token)
|
61
|
+
purchase_transaction = payment_method.purchase(100)
|
62
|
+
purchase_transaction.succeeded? # false
|
63
|
+
|
64
|
+
Other test cards available include :visa, :american_express, and :discover
|
65
|
+
|
66
|
+
Usage
|
67
|
+
----------
|
68
|
+
|
69
|
+
Using spreedly_core in irb:
|
70
|
+
|
71
|
+
require 'spreedly_core'
|
72
|
+
require 'spreedly_core/test_extensions' # allow creating payment methods from the command line
|
73
|
+
SpreedlyCore.configure("Your API Login", "Your API Secret", "Test Gateway Token")
|
74
|
+
master_card_data = SpreedlyCore::TestHelper.cc_data(:master)
|
75
|
+
token = SpreedlyCore::PaymentMethod.create_test_token(master_card_data)
|
76
|
+
|
77
|
+
|
78
|
+
Look up a payment method:
|
79
|
+
|
80
|
+
payment_method = SpreedlyCore::PaymentMethod.find(token)
|
81
|
+
|
82
|
+
Retain a payment method for later use:
|
83
|
+
|
84
|
+
retain_transaction = payment_method.retain
|
85
|
+
retain_transaction.succeeded? # true
|
86
|
+
|
87
|
+
Redact a previously retained payment method:
|
88
|
+
|
89
|
+
redact_transaction = payment_method.redact
|
90
|
+
redact_transaction.succeeded?
|
91
|
+
|
92
|
+
Make a purchase against a payment method:
|
93
|
+
|
94
|
+
purchase_transaction = payment_method.purchase(100)
|
95
|
+
purchase_transaction.succeeded? # true
|
96
|
+
|
97
|
+
Make an authorize request against a payment method, then capture the payment
|
98
|
+
|
99
|
+
authorize = payment_method.authorize(100)
|
100
|
+
authorize.succeeded? # true
|
101
|
+
capture = authorize.capture(50) # Capture only half of the authorized amount
|
102
|
+
capture.succeeded? # true
|
103
|
+
|
104
|
+
authorize = payment_method.authorize(100)
|
105
|
+
authorize.succeeded? # true
|
106
|
+
authorized.capture # Capture the full amount
|
107
|
+
capture.succeeded? # true
|
108
|
+
|
109
|
+
Void a previous purchase:
|
110
|
+
|
111
|
+
purchase_transaction.void # void the purchase
|
112
|
+
|
113
|
+
Credit a previous purchase:
|
114
|
+
|
115
|
+
purchase_transaction = payment_method.purchase(100) # make a purchase
|
116
|
+
purchase_transaction.credit
|
117
|
+
purchase_transaction.succeeded? # true
|
118
|
+
|
119
|
+
Credit part of a previous purchase:
|
120
|
+
|
121
|
+
purchase_transaction = payment_method.purchase(100) # make a purchase
|
122
|
+
purchase_transaction.credit(50) # provide a partial credit
|
123
|
+
purchase_transaction.succeeded? # true
|
124
|
+
|
125
|
+
|
126
|
+
Additional Field Validation
|
127
|
+
----------
|
128
|
+
|
129
|
+
|
130
|
+
The Spreedyly Core API provides validation of the credit card number, cve, and
|
131
|
+
first and last name. In most cases this is enough, however sometimes you want to
|
132
|
+
enforce the billing information as well. This can be accomplished via:
|
133
|
+
|
134
|
+
require 'spreedly_core'
|
135
|
+
require 'spreedly_core/test_extensions'
|
136
|
+
SpreedlyCore.configure("Your API Login", "Your API Secret", "Test Gateway Token")
|
137
|
+
SpreedlyCore::PaymentMethod.additional_required_cc_fields :address1, :city, :state, :zip
|
138
|
+
master_card_data = SpreedlyCore::TestHelper.cc_data(:master)
|
139
|
+
token = SpreedlyCore::PaymentMethod.create_test_token(master_card_data)
|
140
|
+
payment_method = SpreedlyCore::PaymentMethod.find(token)
|
141
|
+
payment_method.valid? # returns false
|
142
|
+
payment_method.errors # ["Address1 can't be blank", "City can't be blank", "State can't be blank", "Zip can't be blank"]
|
143
|
+
master_card_data = SpreedlyCore::TestHelper.cc_data(:master, :credit_card => {:address1 => "742 Evergreen Terrace", :city => "Springfield", :state => "IL", 62701})
|
144
|
+
payment_method = SpreedlyCore::PaymentMethod.find(token)
|
145
|
+
payment_method.valid? # returns true
|
146
|
+
payment_method.errors # []
|
147
|
+
|
148
|
+
|
149
|
+
Configuring SpreedlyCore with Rails
|
150
|
+
----------
|
151
|
+
|
152
|
+
Inside your Rails project create config/spreedly_core.yml formatted like config/database.yml. For example:
|
153
|
+
|
154
|
+
development:
|
155
|
+
login: <Login Key>
|
156
|
+
secret: <Secret Key>
|
157
|
+
gateway_token: 'JncEWj22g59t3CRB1VnPXmUUgKc' # this is the test gateway, replace with your real gateway in production
|
158
|
+
test:
|
159
|
+
login: <Login Key>
|
160
|
+
secret: <Secret Key>
|
161
|
+
gateway_token: 'JncEWj22g59t3CRB1VnPXmUUgKc' # this is the test gateway, replace with your real gateway in production
|
162
|
+
production:
|
163
|
+
login: <Login Key>
|
164
|
+
secret: <Secret Key>
|
165
|
+
gateway_token: 'JncEWj22g59t3CRB1VnPXmUUgKc' # this is the test gateway, replace with your real gateway in production
|
166
|
+
|
167
|
+
Then create config/initializers/spreedly_core.rb with the following:
|
168
|
+
|
169
|
+
config = YAML.load(File.read(RAILS_ROOT + '/config/spreedly_core.yml'))[RAILS_ENV]
|
170
|
+
SpreedlyCore.configure(config['login'], config['secret'], config['gateway_token'])
|
171
|
+
|
172
|
+
Optionally require additional credit card fields:
|
173
|
+
|
174
|
+
SpreedlyCore::PaymentMethod.additional_required_cc_fields :address1, :city, :state, :zip
|
175
|
+
|
176
|
+
Contributing
|
177
|
+
------------
|
178
|
+
|
179
|
+
Once you've made your commits:
|
180
|
+
|
181
|
+
1. [Fork](http://help.github.com/forking/) SpreedlyCore
|
182
|
+
2. Create a topic branch - `git checkout -b my_branch`
|
183
|
+
3. Push to your branch - `git push origin my_branch`
|
184
|
+
4. Create a [Pull Request](http://help.github.com/pull-requests/) from your branch
|
185
|
+
5. Profit!
|
186
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'rake/testtask'
|
2
|
+
|
3
|
+
Rake::TestTask.new(:test) do |test|
|
4
|
+
test.libs << 'lib' << 'test'
|
5
|
+
test.pattern = 'test/**/*_test.rb'
|
6
|
+
test.verbose = true
|
7
|
+
end
|
8
|
+
|
9
|
+
begin
|
10
|
+
require 'rcov/rcovtask'
|
11
|
+
Rcov::RcovTask.new do |test|
|
12
|
+
test.libs << 'test'
|
13
|
+
test.pattern = 'test/**/test_*.rb'
|
14
|
+
test.verbose = true
|
15
|
+
end
|
16
|
+
rescue LoadError
|
17
|
+
task :rcov do
|
18
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
task :default => :test
|
23
|
+
|
24
|
+
require 'rake/rdoctask'
|
25
|
+
Rake::RDocTask.new do |rdoc|
|
26
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
27
|
+
|
28
|
+
rdoc.rdoc_dir = 'rdoc'
|
29
|
+
rdoc.title = "someproject #{version}"
|
30
|
+
rdoc.rdoc_files.include('README*')
|
31
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
32
|
+
end
|
33
|
+
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module SpreedlyCore
|
2
|
+
# Base class for all SpreedlyCore API requests
|
3
|
+
class Base
|
4
|
+
include HTTParty
|
5
|
+
|
6
|
+
# Net::HTTP::Options is configured to not have a body.
|
7
|
+
# Lets give it the body it's always dreamed of
|
8
|
+
Net::HTTP::Options::RESPONSE_HAS_BODY = true
|
9
|
+
|
10
|
+
format :xml
|
11
|
+
|
12
|
+
# timeout requests after 10 seconds
|
13
|
+
default_timeout 10
|
14
|
+
|
15
|
+
base_uri "https://spreedlycore.com/v1"
|
16
|
+
|
17
|
+
def self.configure(login, secret, gateway_token)
|
18
|
+
@@login = login
|
19
|
+
self.basic_auth(login, secret)
|
20
|
+
@@gateway_token = gateway_token
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.login; @@login; end
|
24
|
+
def self.gateway_token; @@gateway_token; end
|
25
|
+
|
26
|
+
# make a post request to path
|
27
|
+
# If the request succeeds, provide the respones to the &block
|
28
|
+
def self.verify_post(path, options={}, &block)
|
29
|
+
verify_request(:post, path, options, 200, 422, &block)
|
30
|
+
end
|
31
|
+
|
32
|
+
# make a put request to path
|
33
|
+
# If the request succeeds, provide the respones to the &block
|
34
|
+
def self.verify_put(path, options={}, &block)
|
35
|
+
verify_request(:put, path, options, &block)
|
36
|
+
end
|
37
|
+
|
38
|
+
# make a get request to path
|
39
|
+
# If the request succeeds, provide the respones to the &block
|
40
|
+
def self.verify_get(path, options={}, &block)
|
41
|
+
verify_request(:get, path, options, 200, &block)
|
42
|
+
end
|
43
|
+
|
44
|
+
# make an options request to path
|
45
|
+
# If the request succeeds, provide the respones to the &block
|
46
|
+
def self.verify_options(path, options={}, &block)
|
47
|
+
verify_request(:options, path, options, 200, &block)
|
48
|
+
end
|
49
|
+
|
50
|
+
# make a request to path using the HTTP method provided as request_type
|
51
|
+
# *allowed_codes are passed in, verify the response code (200, 404, etc)
|
52
|
+
# is one of the allowed codes.
|
53
|
+
# If *allowed_codes is empty, don't check the response code, but set an instance
|
54
|
+
# variable on the object created in the block containing the response code.
|
55
|
+
def self.verify_request(request_type, path, options, *allowed_codes, &block)
|
56
|
+
begin
|
57
|
+
response = self.send(request_type, path, options)
|
58
|
+
rescue Timeout::Error, Errno::ETIMEDOUT => e
|
59
|
+
raise TimeOutError.new("Request to #{path} timed out. Is Spreedly Core down?")
|
60
|
+
end
|
61
|
+
|
62
|
+
if allowed_codes.any?
|
63
|
+
if allowed_codes.include?(response.code)
|
64
|
+
block.call(response)
|
65
|
+
else
|
66
|
+
raise "Error retrieving #{path}. Got status of #{response.code}. Expected status to be in #{allowed_codes.join(",")}\n#{response.body}"
|
67
|
+
end
|
68
|
+
else
|
69
|
+
block.call(response).tap do |obj|
|
70
|
+
obj.instance_variable_set("@http_code", response.code)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Given a hash of attrs, assign instance variables using the hash key as the
|
76
|
+
# attribute name and hash value as the attribute value
|
77
|
+
#
|
78
|
+
def initialize(attrs={})
|
79
|
+
attrs.each do |k, v|
|
80
|
+
instance_variable_set("@#{k}", v)
|
81
|
+
end
|
82
|
+
# errors may be nil, empty, a string, or an array of strings.
|
83
|
+
@errors = if @errors.nil? || @errors["error"].blank?
|
84
|
+
[]
|
85
|
+
elsif @errors["error"].is_a?(String)
|
86
|
+
[@errors["error"]]
|
87
|
+
else
|
88
|
+
@errors["error"]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module SpreedlyCore
|
2
|
+
class Gateway < Base
|
3
|
+
attr_reader(:name, :gateway_type, :auth_modes, :supports_capture,
|
4
|
+
:supports_authorize, :supports_purchase, :supports_void,
|
5
|
+
:supports_credit)
|
6
|
+
|
7
|
+
# returns an array of Gateway which are supported
|
8
|
+
def self.supported_gateways
|
9
|
+
verify_options("/gateways.xml") do |response|
|
10
|
+
response.parsed_response["gateways"]["gateway"].map{|h| new(h) }
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(attrs={})
|
15
|
+
attrs.merge!(attrs.delete("characteristics") || {})
|
16
|
+
super(attrs)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
module SpreedlyCore
|
2
|
+
class PaymentMethod < Base
|
3
|
+
attr_reader( :address1, :address2, :card_type, :city, :country, :created_at,
|
4
|
+
:data, :email, :errors, :first_name, :last_four_digits,
|
5
|
+
:last_name, :month, :number, :payment_method_type, :phone_number,
|
6
|
+
:state, :token, :updated_at, :verification_value, :year, :zip)
|
7
|
+
|
8
|
+
# configure additional required fiels. Like :address1, :city, :state
|
9
|
+
def self.additional_required_cc_fields *fields
|
10
|
+
@@additional_required_fields ||= Set.new
|
11
|
+
@@additional_required_fields += fields
|
12
|
+
end
|
13
|
+
|
14
|
+
# clear the configured additional required fields
|
15
|
+
def self.reset_additional_required_cc_fields
|
16
|
+
@@additional_required_fields = Set.new
|
17
|
+
end
|
18
|
+
|
19
|
+
# Lookup the PaymentMethod by token
|
20
|
+
def self.find(token)
|
21
|
+
return nil if token.nil?
|
22
|
+
verify_get("/payment_methods/#{token}.xml") do |response|
|
23
|
+
new(response.parsed_response["payment_method"])
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Create a new PaymentMethod based on the attrs hash and then validate
|
28
|
+
def initialize(attrs={})
|
29
|
+
super(attrs)
|
30
|
+
validate
|
31
|
+
end
|
32
|
+
|
33
|
+
# Retain the payment method
|
34
|
+
def retain
|
35
|
+
self.class.verify_put("/payment_methods/#{token}/retain.xml", :body => {}) do |response|
|
36
|
+
RetainTransaction.new(response.parsed_response["transaction"])
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Redact the payment method
|
41
|
+
def redact
|
42
|
+
self.class.verify_put("/payment_methods/#{token}/redact.xml", :body => {}) do |response|
|
43
|
+
RedactTransaction.new(response.parsed_response["transaction"])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Make a purchase against the payment method
|
48
|
+
def purchase(amount, currency="USD", _gateway_token=nil)
|
49
|
+
purchase_or_authorize(:purchase, amount, currency, _gateway_token)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Make an authorize against payment method. You can then later capture against the authorize
|
53
|
+
def authorize(amount, currency="USD", _gateway_token=nil)
|
54
|
+
purchase_or_authorize(:authorize, amount, currency, _gateway_token)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns the URL that CC data should be submitted to.
|
58
|
+
def self.submit_url
|
59
|
+
Base.base_uri + '/payment_methods'
|
60
|
+
end
|
61
|
+
|
62
|
+
def valid?
|
63
|
+
@errors.empty?
|
64
|
+
end
|
65
|
+
|
66
|
+
protected
|
67
|
+
|
68
|
+
# Validate additional cc fields like first_name, last_name, etc when
|
69
|
+
# configured to do so
|
70
|
+
def validate
|
71
|
+
return if @has_been_validated
|
72
|
+
@has_been_validated = true
|
73
|
+
self.class.additional_required_cc_fields.each do |field|
|
74
|
+
if instance_variable_get("@#{field}").blank?
|
75
|
+
str_field= field.to_s
|
76
|
+
friendly_name = if str_field.respond_to?(:humanize)
|
77
|
+
str_field.humanize
|
78
|
+
else
|
79
|
+
str_field.split("_").join(" ")
|
80
|
+
end
|
81
|
+
|
82
|
+
@errors << "#{friendly_name.capitalize} can't be blank"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
@errors = @errors.sort
|
86
|
+
end
|
87
|
+
|
88
|
+
def purchase_or_authorize(tran_type, amount, currency, _gateway_token)
|
89
|
+
transaction_type = tran_type.to_s
|
90
|
+
raise "Unknown transaction type" unless %w{purchase authorize}.include?(transaction_type)
|
91
|
+
|
92
|
+
_gateway_token ||= self.class.gateway_token
|
93
|
+
path = "/gateways/#{_gateway_token}/#{transaction_type}.xml"
|
94
|
+
data = {:transaction => {
|
95
|
+
:amount => amount,
|
96
|
+
:transaction_type => transaction_type,
|
97
|
+
:payment_method_token => token,
|
98
|
+
:currency_code => currency }}
|
99
|
+
|
100
|
+
self.class.verify_post(path, :body => data) do |response|
|
101
|
+
klass = SpreedlyCore.const_get("#{transaction_type.capitalize}Transaction")
|
102
|
+
klass.new(response.parsed_response["transaction"])
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
|
3
|
+
module SpreedlyCore::TestHelper
|
4
|
+
extend self
|
5
|
+
|
6
|
+
def cc_data(cc_type, options={})
|
7
|
+
|
8
|
+
card_numbers = {:master => [5555555555554444, 5105105105105100],
|
9
|
+
:visa => [4111111111111111, 4012888888881881],
|
10
|
+
:american_express => [378282246310005, 371449635398431],
|
11
|
+
:discover => [6011111111111117, 6011000990139424]
|
12
|
+
}
|
13
|
+
|
14
|
+
card_number = options[:card_number] == :failed ? :last : :first
|
15
|
+
number = card_numbers[cc_type].send(card_number)
|
16
|
+
|
17
|
+
{ :credit_card => {
|
18
|
+
:first_name => "John",
|
19
|
+
:last_name => "Foo",
|
20
|
+
:card_type => cc_type,
|
21
|
+
:number => number,
|
22
|
+
:verification_value => 123,
|
23
|
+
:month => 4,
|
24
|
+
:year => Time.now.year + 1 }.merge(options[:credit_card] || {})
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
module SpreedlyCore
|
30
|
+
class PaymentMethod
|
31
|
+
def self.create_test_token(cc_data={})
|
32
|
+
data = cc_data.merge(:redirect_url => "http://example.com",
|
33
|
+
:api_login => SpreedlyCore::Base.login)
|
34
|
+
|
35
|
+
response = self.post("/payment_methods", :body => data, :no_follow => true)
|
36
|
+
rescue HTTParty::RedirectionTooDeep => e
|
37
|
+
if e.response.body =~ /href="(.*?)"/
|
38
|
+
# rescuing the redirection too deep is apparently the way to
|
39
|
+
# handle redirect following
|
40
|
+
token = CGI::parse(URI.parse($1).query)["token"].first
|
41
|
+
end
|
42
|
+
raise "Could not find token in body: #{response}" if token.nil?
|
43
|
+
return token
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
@@ -0,0 +1,127 @@
|
|
1
|
+
module SpreedlyCore
|
2
|
+
# Abstract class for all the different spreedly core transactions
|
3
|
+
class Transaction < Base
|
4
|
+
attr_reader(:amount, :on_test_gateway, :created_at, :updated_at, :currency_code,
|
5
|
+
:succeeded, :token, :message, :transaction_type, :gateway_token,
|
6
|
+
:response)
|
7
|
+
alias :succeeded? :succeeded
|
8
|
+
|
9
|
+
# Breaks enacapsulation a bit, but allow subclasses to register the 'transaction_type'
|
10
|
+
# they handle.
|
11
|
+
def self.handles(transaction_type)
|
12
|
+
@@transaction_type_to_class ||= {}
|
13
|
+
@@transaction_type_to_class[transaction_type] = self
|
14
|
+
end
|
15
|
+
|
16
|
+
# Lookup the transaction by its token. Returns the correct subclass
|
17
|
+
def self.find(token)
|
18
|
+
return nil if token.nil?
|
19
|
+
verify_get("/transactions/#{token}.xml") do |response|
|
20
|
+
attrs = response.parsed_response["transaction"]
|
21
|
+
klass = @@transaction_type_to_class[attrs["transaction_type"]] || self
|
22
|
+
klass.new(attrs)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
class RetainTransaction < Transaction
|
28
|
+
handles "RetainPaymentMethod"
|
29
|
+
attr_reader :payment_method
|
30
|
+
|
31
|
+
def initialize(attrs={})
|
32
|
+
@payment_method = PaymentMethod.new(attrs.delete("payment_method") || {})
|
33
|
+
super(attrs)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
class RedactTransaction < Transaction
|
37
|
+
handles "RedactPaymentMethod"
|
38
|
+
attr_reader :payment_method
|
39
|
+
|
40
|
+
def initialize(attrs={})
|
41
|
+
@payment_method = PaymentMethod.new(attrs.delete("payment_method") || {})
|
42
|
+
super(attrs)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
module NullifiableTransaction
|
47
|
+
# Void is used to cancel out authorizations and, with some gateways, to
|
48
|
+
# cancel actual payment transactions within the first 24 hours
|
49
|
+
def void
|
50
|
+
self.class.verify_post("/transactions/#{token}/void.xml") do |response|
|
51
|
+
VoidedTransaction.new(response.parsed_response["transaction"])
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Credit amount. If amount is nil, then credit the entire previous purchase
|
56
|
+
# or captured amount
|
57
|
+
def credit(amount=nil)
|
58
|
+
body = if amount.nil?
|
59
|
+
{}
|
60
|
+
else
|
61
|
+
{:transaction => {:amount => amount}}
|
62
|
+
end
|
63
|
+
self.class.verify_post("/transactions/#{token}/credit.xml",
|
64
|
+
:body => body) do |response|
|
65
|
+
CreditTransaction.new(response.parsed_response["transaction"])
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
class AuthorizeTransaction < Transaction
|
71
|
+
handles "authorization"
|
72
|
+
attr_reader :payment_method
|
73
|
+
|
74
|
+
def initialize(attrs={})
|
75
|
+
@payment_method = PaymentMethod.new(attrs.delete("payment_method") || {})
|
76
|
+
@response = Response.new(attrs.delete("response") || {})
|
77
|
+
super(attrs)
|
78
|
+
end
|
79
|
+
|
80
|
+
# Capture the previously authorized payment. If the amount is nil, the
|
81
|
+
# captured amount will the amount from the original authorization. Some
|
82
|
+
# gateways support partial captures which can be done by specifiying an
|
83
|
+
# amount
|
84
|
+
def capture(amount=nil)
|
85
|
+
body = if amount.nil?
|
86
|
+
{}
|
87
|
+
else
|
88
|
+
{:transaction => {:amount => amount}}
|
89
|
+
end
|
90
|
+
self.class.verify_post("/transactions/#{token}/capture.xml",
|
91
|
+
:body => body) do |response|
|
92
|
+
CaptureTransaction.new(response.parsed_response["transaction"])
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
class PurchaseTransaction < Transaction
|
98
|
+
include NullifiableTransaction
|
99
|
+
|
100
|
+
handles "purchase"
|
101
|
+
attr_reader :payment_method
|
102
|
+
|
103
|
+
def initialize(attrs={})
|
104
|
+
@payment_method = PaymentMethod.new(attrs.delete("payment_method") || {})
|
105
|
+
@response = Response.new(attrs.delete("response") || {})
|
106
|
+
super(attrs)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
class CaptureTransaction < Transaction
|
111
|
+
include NullifiableTransaction
|
112
|
+
|
113
|
+
handles "capture"
|
114
|
+
attr_reader :reference_token
|
115
|
+
end
|
116
|
+
|
117
|
+
class VoidedTransaction < Transaction
|
118
|
+
handles "void"
|
119
|
+
attr_reader :reference_token
|
120
|
+
end
|
121
|
+
|
122
|
+
class CreditTransaction < Transaction
|
123
|
+
handles "credit"
|
124
|
+
attr_reader :reference_token
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
require 'httparty'
|
4
|
+
|
5
|
+
require 'spreedly_core/base'
|
6
|
+
require 'spreedly_core/payment_method'
|
7
|
+
require 'spreedly_core/gateway'
|
8
|
+
require 'spreedly_core/transactions'
|
9
|
+
|
10
|
+
module SpreedlyCore
|
11
|
+
# Hash of user friendly credit card name to SpreedlyCore API name
|
12
|
+
CARD_TYPES = {
|
13
|
+
"Visa" => "visa",
|
14
|
+
"MasterCard" => "master",
|
15
|
+
"American Express" => "american_express",
|
16
|
+
"Discover" => "discover"
|
17
|
+
}
|
18
|
+
|
19
|
+
# Custom exception which occurs when a request to SpreedlyCore times out
|
20
|
+
# See SpreedlyCore::Base.default_timeout
|
21
|
+
class TimeOutError < RuntimeError; end
|
22
|
+
|
23
|
+
# Configure SpreedlyCore with a particular account and default gateway
|
24
|
+
def self.configure(login, secret, gateway_token)
|
25
|
+
Base.configure(login, secret, gateway_token)
|
26
|
+
end
|
27
|
+
|
28
|
+
# returns the configured SpreedlyCore login
|
29
|
+
def self.login; Base.login; end
|
30
|
+
|
31
|
+
# A container for a response from a payment gateway
|
32
|
+
class Response < Base
|
33
|
+
attr_reader(:success, :message, :avs_code, :avs_message, :cvv_code,
|
34
|
+
:cvv_message, :error_code, :error_detail, :created_at,
|
35
|
+
:updated_at)
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,237 @@
|
|
1
|
+
require "#{File.dirname(__FILE__)}/test_helper"
|
2
|
+
|
3
|
+
|
4
|
+
# In order to run tests
|
5
|
+
# 1. cp test/config/spreedly_core.yml.example to test/config/spreedly_core.yml
|
6
|
+
# 2. Add your spreedly core credentials to test/config/spreedly_core.yml
|
7
|
+
class SpreedlyCoreTest < Test::Unit::TestCase
|
8
|
+
include SpreedlyCore::TestHelper
|
9
|
+
|
10
|
+
def setup
|
11
|
+
SpreedlyCore::PaymentMethod.reset_additional_required_cc_fields
|
12
|
+
end
|
13
|
+
|
14
|
+
def given_a_payment_method(cc_card=:master, card_options={})
|
15
|
+
token = SpreedlyCore::PaymentMethod.create_test_token(cc_data(cc_card, card_options))
|
16
|
+
assert payment_method = SpreedlyCore::PaymentMethod.find(token)
|
17
|
+
assert token, payment_method.token
|
18
|
+
payment_method
|
19
|
+
end
|
20
|
+
|
21
|
+
def given_a_purchase(purchase_amount=100)
|
22
|
+
payment_method = given_a_payment_method
|
23
|
+
assert transaction = payment_method.purchase(purchase_amount)
|
24
|
+
assert_equal purchase_amount, transaction.amount
|
25
|
+
assert_equal "USD", transaction.currency_code
|
26
|
+
assert_equal "purchase", transaction.transaction_type
|
27
|
+
assert transaction.succeeded?
|
28
|
+
transaction
|
29
|
+
end
|
30
|
+
|
31
|
+
def given_a_retained_transaction
|
32
|
+
payment_method = given_a_payment_method
|
33
|
+
assert transaction = payment_method.retain
|
34
|
+
assert transaction.succeeded?
|
35
|
+
assert_equal "RetainPaymentMethod", transaction.transaction_type
|
36
|
+
transaction
|
37
|
+
end
|
38
|
+
|
39
|
+
def given_a_redacted_transaction
|
40
|
+
retained_transaction = given_a_retained_transaction
|
41
|
+
assert payment_method = retained_transaction.payment_method
|
42
|
+
transaction = payment_method.redact
|
43
|
+
assert transaction.succeeded?
|
44
|
+
assert_equal "RedactPaymentMethod", transaction.transaction_type
|
45
|
+
assert !transaction.token.blank?
|
46
|
+
transaction
|
47
|
+
end
|
48
|
+
|
49
|
+
def given_an_authorized_transaction(amount=100)
|
50
|
+
payment_method = given_a_payment_method
|
51
|
+
assert transaction = payment_method.authorize(100)
|
52
|
+
assert_equal 100, transaction.amount
|
53
|
+
assert_equal "USD", transaction.currency_code
|
54
|
+
assert_equal SpreedlyCore::AuthorizeTransaction, transaction.class
|
55
|
+
transaction
|
56
|
+
end
|
57
|
+
|
58
|
+
def given_a_capture(amount=100)
|
59
|
+
transaction = given_an_authorized_transaction
|
60
|
+
capture = transaction.capture(amount)
|
61
|
+
assert capture.succeeded?
|
62
|
+
assert_equal amount, capture.amount
|
63
|
+
assert_equal "capture", capture.transaction_type
|
64
|
+
assert_equal SpreedlyCore::CaptureTransaction, capture.class
|
65
|
+
capture
|
66
|
+
end
|
67
|
+
|
68
|
+
def given_a_purchase_void
|
69
|
+
purchase = given_a_purchase
|
70
|
+
assert void = purchase.void
|
71
|
+
assert_equal purchase.token, void.reference_token
|
72
|
+
assert void.succeeded?
|
73
|
+
void
|
74
|
+
end
|
75
|
+
|
76
|
+
def given_a_capture_void
|
77
|
+
capture = given_a_capture
|
78
|
+
assert void = capture.void
|
79
|
+
assert_equal capture.token, void.reference_token
|
80
|
+
assert void.succeeded?
|
81
|
+
void
|
82
|
+
end
|
83
|
+
|
84
|
+
def given_a_purchase_credit(purchase_amount=100, credit_amount=100)
|
85
|
+
purchase = given_a_purchase(purchase_amount)
|
86
|
+
given_a_credit(purchase, credit_amount)
|
87
|
+
end
|
88
|
+
|
89
|
+
def given_a_capture_credit(capture_amount=100, credit_amount=100)
|
90
|
+
capture = given_a_capture(capture_amount)
|
91
|
+
given_a_credit(capture, credit_amount)
|
92
|
+
end
|
93
|
+
|
94
|
+
def given_a_credit(trans, credit_amount=100)
|
95
|
+
assert credit = trans.credit(credit_amount)
|
96
|
+
assert_equal trans.token, credit.reference_token
|
97
|
+
assert_equal credit_amount, credit.amount
|
98
|
+
assert credit.succeeded?
|
99
|
+
assert SpreedlyCore::CreditTransaction, credit.class
|
100
|
+
credit
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
|
105
|
+
def test_can_get_payment_token
|
106
|
+
payment_method = given_a_payment_method
|
107
|
+
assert_equal "John", payment_method.first_name
|
108
|
+
assert_equal "Foo", payment_method.last_name
|
109
|
+
assert_equal "XXX", payment_method.verification_value
|
110
|
+
assert payment_method.errors.empty?
|
111
|
+
assert_equal 4, payment_method.month
|
112
|
+
assert_equal 2015, payment_method.year
|
113
|
+
end
|
114
|
+
|
115
|
+
def test_can_retain_payment_method
|
116
|
+
given_a_retained_transaction
|
117
|
+
end
|
118
|
+
|
119
|
+
def test_can_redact_payment_method
|
120
|
+
given_a_redacted_transaction
|
121
|
+
end
|
122
|
+
|
123
|
+
def test_can_make_purchase
|
124
|
+
given_a_purchase
|
125
|
+
end
|
126
|
+
|
127
|
+
def test_can_authorize
|
128
|
+
given_an_authorized_transaction
|
129
|
+
end
|
130
|
+
|
131
|
+
def test_payment_failed
|
132
|
+
payment_method = given_a_payment_method(:master, :card_number => :failed)
|
133
|
+
|
134
|
+
assert transaction = payment_method.purchase(100)
|
135
|
+
assert !transaction.succeeded?
|
136
|
+
assert_equal("Unable to obtain a successful response from the gateway.",
|
137
|
+
transaction.message)
|
138
|
+
|
139
|
+
assert_equal("Unable to process the transaction.", transaction.response.message)
|
140
|
+
end
|
141
|
+
|
142
|
+
def test_can_capture_after_authorize
|
143
|
+
given_a_capture
|
144
|
+
end
|
145
|
+
|
146
|
+
def test_can_capture_partial_after_authorize
|
147
|
+
given_a_capture 50
|
148
|
+
end
|
149
|
+
|
150
|
+
def test_can_void_after_purchase
|
151
|
+
given_a_purchase_void
|
152
|
+
end
|
153
|
+
|
154
|
+
def test_can_void_after_capture
|
155
|
+
given_a_capture_void
|
156
|
+
end
|
157
|
+
|
158
|
+
def test_can_credit_after_purchase
|
159
|
+
given_a_purchase_credit
|
160
|
+
end
|
161
|
+
|
162
|
+
def test_can_credit_partial_after_purchase
|
163
|
+
given_a_purchase_credit(100, 50)
|
164
|
+
end
|
165
|
+
|
166
|
+
def test_can_credit_after_capture
|
167
|
+
given_a_capture_credit
|
168
|
+
end
|
169
|
+
|
170
|
+
def test_can_credit_partial_after_capture
|
171
|
+
given_a_capture_credit(50, 25)
|
172
|
+
end
|
173
|
+
|
174
|
+
def test_find_returns_retain_transaction_type
|
175
|
+
retain = given_a_retained_transaction
|
176
|
+
assert_find_transaction(retain, SpreedlyCore::RetainTransaction)
|
177
|
+
end
|
178
|
+
|
179
|
+
def test_find_returns_redact_transaction_type
|
180
|
+
redact = given_a_redacted_transaction
|
181
|
+
assert_find_transaction(redact, SpreedlyCore::RedactTransaction)
|
182
|
+
end
|
183
|
+
|
184
|
+
def test_find_returns_authorize_transaction_type
|
185
|
+
authorize = given_an_authorized_transaction
|
186
|
+
assert_find_transaction(authorize, SpreedlyCore::AuthorizeTransaction)
|
187
|
+
end
|
188
|
+
|
189
|
+
def test_find_returns_purchase_transaction_type
|
190
|
+
purchase = given_a_purchase
|
191
|
+
assert_find_transaction(purchase, SpreedlyCore::PurchaseTransaction)
|
192
|
+
end
|
193
|
+
|
194
|
+
def test_find_returns_capture_transaction_type
|
195
|
+
capture = given_a_capture
|
196
|
+
assert_find_transaction(capture, SpreedlyCore::CaptureTransaction)
|
197
|
+
end
|
198
|
+
|
199
|
+
def test_find_returns_voided_transaction_type
|
200
|
+
void = given_a_capture_void
|
201
|
+
assert_find_transaction(void, SpreedlyCore::VoidedTransaction)
|
202
|
+
end
|
203
|
+
|
204
|
+
def test_find_returns_credit_transaction_type
|
205
|
+
credit = given_a_capture_credit
|
206
|
+
assert_find_transaction(credit, SpreedlyCore::CreditTransaction)
|
207
|
+
end
|
208
|
+
|
209
|
+
|
210
|
+
def test_can_enforce_additional_payment_method_validations
|
211
|
+
SpreedlyCore::PaymentMethod.additional_required_cc_fields :state
|
212
|
+
|
213
|
+
token = SpreedlyCore::PaymentMethod.create_test_token(cc_data(:master))
|
214
|
+
assert payment_method = SpreedlyCore::PaymentMethod.find(token)
|
215
|
+
assert !payment_method.valid?
|
216
|
+
assert_equal 1, payment_method.errors.size
|
217
|
+
|
218
|
+
assert_equal "State can't be blank", payment_method.errors.first
|
219
|
+
|
220
|
+
token = SpreedlyCore::PaymentMethod.
|
221
|
+
create_test_token(cc_data(:master, :credit_card => {:state => "IL"}))
|
222
|
+
|
223
|
+
assert payment_method = SpreedlyCore::PaymentMethod.find(token)
|
224
|
+
|
225
|
+
assert payment_method.valid?
|
226
|
+
end
|
227
|
+
|
228
|
+
def test_can_list_supported_gateways
|
229
|
+
assert SpreedlyCore::Gateway.supported_gateways.any?
|
230
|
+
end
|
231
|
+
|
232
|
+
protected
|
233
|
+
def assert_find_transaction(trans, expected_class)
|
234
|
+
assert actual = SpreedlyCore::Transaction.find(trans.token)
|
235
|
+
assert_equal expected_class, actual.class
|
236
|
+
end
|
237
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'bundler'
|
5
|
+
|
6
|
+
Bundler.setup(:default, :development)
|
7
|
+
|
8
|
+
Bundler.require(:default, :development)
|
9
|
+
|
10
|
+
$LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib'
|
11
|
+
require 'spreedly_core'
|
12
|
+
require 'spreedly_core/test_extensions'
|
13
|
+
|
14
|
+
|
15
|
+
config = YAML.load(File.read(File.dirname(__FILE__) + '/config/spreedly_core.yml'))
|
16
|
+
SpreedlyCore.configure(config['login'], config['secret'], config['gateway_token'])
|
17
|
+
|
metadata
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: spreedly_core
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 27
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
- 2
|
10
|
+
version: 0.0.2
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- 403 Labs
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2011-05-18 00:00:00 Z
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: httparty
|
22
|
+
prerelease: false
|
23
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
24
|
+
none: false
|
25
|
+
requirements:
|
26
|
+
- - "="
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
hash: 13
|
29
|
+
segments:
|
30
|
+
- 0
|
31
|
+
- 7
|
32
|
+
- 7
|
33
|
+
version: 0.7.7
|
34
|
+
type: :runtime
|
35
|
+
version_requirements: *id001
|
36
|
+
- !ruby/object:Gem::Dependency
|
37
|
+
name: ruby-debug
|
38
|
+
prerelease: false
|
39
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
40
|
+
none: false
|
41
|
+
requirements:
|
42
|
+
- - ">="
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
hash: 3
|
45
|
+
segments:
|
46
|
+
- 0
|
47
|
+
version: "0"
|
48
|
+
type: :development
|
49
|
+
version_requirements: *id002
|
50
|
+
description:
|
51
|
+
email: github@403labs.com
|
52
|
+
executables: []
|
53
|
+
|
54
|
+
extensions: []
|
55
|
+
|
56
|
+
extra_rdoc_files: []
|
57
|
+
|
58
|
+
files:
|
59
|
+
- README.md
|
60
|
+
- Rakefile
|
61
|
+
- LICENSE
|
62
|
+
- lib/spreedly_core/base.rb
|
63
|
+
- lib/spreedly_core/gateway.rb
|
64
|
+
- lib/spreedly_core/payment_method.rb
|
65
|
+
- lib/spreedly_core/test_extensions.rb
|
66
|
+
- lib/spreedly_core/transactions.rb
|
67
|
+
- lib/spreedly_core/version.rb
|
68
|
+
- lib/spreedly_core.rb
|
69
|
+
- test/config/spreedly_core.yml
|
70
|
+
- test/config/spreedly_core.yml.example
|
71
|
+
- test/spreedly_core_test.rb
|
72
|
+
- test/test_helper.rb
|
73
|
+
homepage: http://github.com/403labs/spreedly_core
|
74
|
+
licenses: []
|
75
|
+
|
76
|
+
post_install_message:
|
77
|
+
rdoc_options: []
|
78
|
+
|
79
|
+
require_paths:
|
80
|
+
- lib
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
82
|
+
none: false
|
83
|
+
requirements:
|
84
|
+
- - ">="
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
hash: 3
|
87
|
+
segments:
|
88
|
+
- 0
|
89
|
+
version: "0"
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
91
|
+
none: false
|
92
|
+
requirements:
|
93
|
+
- - ">="
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
hash: 3
|
96
|
+
segments:
|
97
|
+
- 0
|
98
|
+
version: "0"
|
99
|
+
requirements: []
|
100
|
+
|
101
|
+
rubyforge_project:
|
102
|
+
rubygems_version: 1.7.2
|
103
|
+
signing_key:
|
104
|
+
specification_version: 3
|
105
|
+
summary: Ruby API for Spreedly Core
|
106
|
+
test_files: []
|
107
|
+
|