spook_and_pay 0.2.0.alpha

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 91f5461e5cc5382b312c2e0d9c7a1ddf576a14e2
4
+ data.tar.gz: 78f8ef0364586a4f8d22283b8ccbf79925068d66
5
+ SHA512:
6
+ metadata.gz: 6061677abfdec2995ad03ef6de15d48308f2685f2cd91cbfff58f4a7fc19ef04a2a1adbf626efa3df1af88d951855661af4f49a24d634d21102965376ea479ea
7
+ data.tar.gz: 4b34cc7037809402145583ee019751230ebd3b4bbe04903d9794486c92530236a80a9d2e95c2318c44cdbaad565dd4b5b7e7da1ff56633c2ae8cc2e22d961fb9
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+ Copyright © 2013 Spook and Puff
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ this software and associated documentation files (the “Software”), to deal in
6
+ the Software without restriction, including without limitation the rights to
7
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
8
+ of the Software, and to permit persons to whom the Software is furnished to do
9
+ so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # Spook and Pay
2
+
3
+ A small library which wraps payment and credit card vaulting providers that support transparent redirect.
4
+
5
+ The aim is to make switching between providers easy or even support multiple providers within the same application.
6
+
7
+ Initially this library will support Braintree and SpreedlyCore, with more added as needed.
8
+
9
+ ## Alpha Warning
10
+
11
+ This library is currently in-flight; you're welcome to hack on it, but it's unlikely to be usable.
12
+
13
+ ## Usage
14
+
15
+ All actions are run via an instance of a `SpookAndPay::Providers::Base` subclass. Configuration is dependent on the particular provider you are using. For example, here is how you would configure Braintree:
16
+
17
+ ```
18
+ provider = SpookAndPay::Providers::Braintree.new(
19
+ :development,
20
+ :merchant_id => "...",
21
+ :public_key => "...",
22
+ :secret_key => "...",
23
+ )
24
+ ```
25
+
26
+ You can then use the provider instance to interrogate Braintree and to perform actions.
27
+
28
+ ```
29
+ transaction = provider.transaction('...')
30
+ transaction.status # => :settling
31
+ transaction.can_refund? # => false
32
+ transaction.can_void? # => true
33
+
34
+ result = transaction.void!
35
+ result.successful? # => true
36
+ ```
37
+
38
+ ### Workflow
39
+
40
+ Currently SpookAndPay does not support the direct submission of payment details — credit card numbers etc — but instead relies on payment providers which feature transparent redirect/post for submission of details.
41
+
42
+ Direct submission of details will _never_ be supported, since it raises the specter of PCI-compliance.
43
+
44
+ ### Transparent Redirect
45
+
46
+ TBD
47
+
48
+ ## Todo
49
+
50
+ * Normalise transaction statuses across providers
51
+ * Normalise errors across providers
52
+ * Implement actions on CreditCard e.g. update, delete
@@ -0,0 +1,19 @@
1
+ module SpookAndPay
2
+
3
+ end
4
+
5
+ require 'cgi'
6
+ require 'digest'
7
+ require 'openssl'
8
+ require 'net/http'
9
+
10
+ require 'braintree'
11
+
12
+ require 'spook_and_pay/submission_error'
13
+ require 'spook_and_pay/missing_value_error'
14
+ require 'spook_and_pay/erroring_reader'
15
+ require 'spook_and_pay/credit_card'
16
+ require 'spook_and_pay/result'
17
+ require 'spook_and_pay/transaction'
18
+ require 'spook_and_pay/adapters'
19
+ require 'spook_and_pay/providers'
@@ -0,0 +1 @@
1
+ require 'spook_and_pay/adapters/braintree'
@@ -0,0 +1,109 @@
1
+ module SpookAndPay
2
+ module Adapters
3
+ # A class which wraps the existing Braintree client and lets us use it in
4
+ # a sane way. Specifically, it lets us have multiple sets of credentials,
5
+ # whereas the default behaviour in the lib is to have them global
6
+ class Braintree
7
+ # Accessor for the Braintree::Gateway instance. In general should not be
8
+ # accessed externally, but is put here for debugging etc.
9
+ attr_reader :gateway
10
+
11
+ # Constructs an instance of the Braintree gateway which it then acts as
12
+ # a proxy to.
13
+ #
14
+ # @param [:development, :test, :production] environment
15
+ # @param String merchant_id
16
+ # @param String public_key
17
+ # @param String private_key
18
+ def initialize(environment, merchant_id, public_key, private_key)
19
+ _environment = case environment
20
+ when :production then :production
21
+ when :development, :test then :sandbox
22
+ end
23
+
24
+ config = ::Braintree::Configuration.new(
25
+ :custom_user_agent => ::Braintree::Configuration.instance_variable_get(:@custom_user_agent),
26
+ :endpoint => ::Braintree::Configuration.instance_variable_get(:@endpoint),
27
+ :environment => _environment,
28
+ :logger => ::Braintree::Configuration.logger,
29
+ :merchant_id => merchant_id,
30
+ :private_key => private_key,
31
+ :public_key => public_key
32
+ )
33
+
34
+ @gateway = ::Braintree::Gateway.new(config)
35
+ end
36
+
37
+ # Looks up the transaction from Braintree.
38
+ #
39
+ # @param String id
40
+ # @return [nil, Braintree::Transaction]
41
+ def transaction(id)
42
+ gateway.transaction.find(id)
43
+ end
44
+
45
+ # Looks up credit card details from Braintree. It squashes NotFoundError
46
+ # and just returns nil instead.
47
+ #
48
+ # @param String id
49
+ # @return [Braintree::CreditCard, nil]
50
+ def credit_card(id)
51
+ begin
52
+ gateway.credit_card.find(id)
53
+ rescue ::Braintree::NotFoundError => e
54
+ nil
55
+ end
56
+ end
57
+
58
+ # Generates the hash and query string that needs to be embedded inside
59
+ # of a form in order to interact with Braintree's transparent redirect.
60
+ #
61
+ # @param Hash data
62
+ #
63
+ # @return String
64
+ def transaction_data(data)
65
+ gateway.transparent_redirect.transaction_data(data)
66
+ end
67
+
68
+ # Used to confirm the submission of purchase or authorize transactions
69
+ # via transparent redirect.
70
+ #
71
+ # @param String query_string
72
+ # @return [Braintree::SuccessfulResult, Braintree::ErrorResult]
73
+ def confirm(query_string)
74
+ gateway.transparent_redirect.confirm(query_string)
75
+ end
76
+
77
+ # Captures the funds in an authorized transaction.
78
+ #
79
+ # @param String id
80
+ # @return [Braintree::SuccessfulResult, Braintree::ErrorResult]
81
+ def capture(id)
82
+ gateway.transaction.submit_for_settlement(id)
83
+ end
84
+
85
+ # Refunds the funds in a settled transaction.
86
+ #
87
+ # @param String id
88
+ # @return [Braintree::SuccessfulResult, Braintree::ErrorResult]
89
+ def refund(id)
90
+ gateway.transaction.refund(id)
91
+ end
92
+
93
+ # Voids a transaction.
94
+ #
95
+ # @param String id
96
+ # @return [Braintree::SuccessfulResult, Braintree::ErrorResult]
97
+ def void(id)
98
+ gateway.transaction.void(id)
99
+ end
100
+
101
+ # The target URL for transparent redirects.
102
+ #
103
+ # @return String
104
+ def transparent_redirect_url
105
+ gateway.transparent_redirect.url
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,96 @@
1
+ module SpookAndPay
2
+ # A simple, generic class which wraps the card details retrieved from a
3
+ # provider. This class is entirely read only, since it is only used to
4
+ # as part of inspecting a payment or handling errors.
5
+ class CreditCard
6
+ # This module adds the ::attr_erroring_reader to this class
7
+ extend SpookAndPay::ErroringReader
8
+
9
+ # The basic attributes of the credit card.
10
+ attr_reader :provider, :id
11
+
12
+ # The fields required for a credit card
13
+ FIELDS = [:number, :expiration_month, :expiration_year, :cvv, :card_type, :name, :valid, :expired].freeze
14
+
15
+ # Define readers for all the fields
16
+ attr_reader *FIELDS
17
+
18
+ # Define a subset of the readers as erroring
19
+ attr_erroring_reader :valid, :expired
20
+
21
+ # Construct a new credit card using the ID from the provider and a hash
22
+ # containing the values of the card.
23
+ #
24
+ # @param SpookAndPay::Providers::Base provider
25
+ # @param [Numeric, String] id
26
+ # @param Hash vals
27
+ # @option vals String :number
28
+ # @option vals [String, Numeric] :expiration_month
29
+ # @option vals [String, Numeric] :expiration_year
30
+ # @option vals [String, Numeric] :cvv
31
+ # @option vals String :card_type
32
+ # @option vals String :name
33
+ # @option vals [true, false] :expired
34
+ # @option vals [true, false] :valid
35
+ def initialize(provider, id, vals)
36
+ @provider = provider
37
+ @id = id
38
+ FIELDS.each {|f| instance_variable_set(:"@#{f}", vals[f]) if vals.has_key?(f)}
39
+ end
40
+
41
+ # A getter which takes the card number stored and generates a nice masked
42
+ # version. It also handles the case where the number isn't available and
43
+ # just returns nil instead.
44
+ #
45
+ # @return String
46
+ def number
47
+ if @number.nil? or @number.empty?
48
+ nil
49
+ else
50
+ case card_type
51
+ when 'american_express' then "XXXX-XXXXXX-#{@number}"
52
+ else "XXXX-XXXX-XXXX-#{@number}"
53
+ end
54
+ end
55
+ end
56
+
57
+ # Authorizes a payment of the specified amount. This generates a new
58
+ # transaction that must be later settled.
59
+ #
60
+ # @param [String, Numeric] amount
61
+ # @return SpookAndPay::Result
62
+ def authorize!(amount)
63
+ provider.authorize_via_credit_card(self, amount)
64
+ end
65
+
66
+ # Generates a payment of the specified amount.
67
+ #
68
+ # @param [String, Numeric] amount
69
+ # @return SpookAndPay::Result
70
+ def purchase!(amount)
71
+ provider.purchase_via_credit_card(self, amount)
72
+ end
73
+
74
+ # Deletes the credit card from the provider's vault.
75
+ #
76
+ # @return [true, false]
77
+ def delete!
78
+ provider.delete_credit_card(self)
79
+ end
80
+
81
+ # Indicates if the card details are valid.
82
+ #
83
+ # @return [true, false]
84
+ def valid?
85
+ valid
86
+ end
87
+
88
+ # Indicates if the card is expired. This is not calculated, but instead
89
+ # determined by the provider.
90
+ #
91
+ # @return [true, false]
92
+ def expired?
93
+ expired
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,23 @@
1
+ module SpookAndPay
2
+ module ErroringReader
3
+ # Defines a set of readers which will error if the underlying ivar is nil.
4
+ # It is intended to be used with a sub-set of readers which are important,
5
+ # but which may be nil. This is preferable to returning nil, which is falsy
6
+ # and will screw up any predicates.
7
+ #
8
+ # @param Symbol ivars
9
+ # @return nil
10
+ def attr_erroring_reader(*ivars)
11
+ ivars.each do |i|
12
+ class_eval %{
13
+ def #{i}
14
+ raise MissingValueError.new(:#{i}, self) unless defined?(@#{i})
15
+ @#{i}
16
+ end
17
+ }
18
+ end
19
+
20
+ nil
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ module SpookAndPay
2
+ # A simple error class used to capture situations where the user is
3
+ # attampting to access a value, but it is not available. This is
4
+ # unfortunately necessary due to the way some providers do or do not
5
+ # return certain fields. Rather than allow comparison with nil values
6
+ # we throw this error.
7
+ class MissingValueError < StandardError
8
+ # When instancing this error, it needs to have enough information to point
9
+ # the user to the source.
10
+ #
11
+ # @param [String, Symbol] field
12
+ # @param Class record
13
+ def initialize(field, record)
14
+ @field = field
15
+ @record = record
16
+ end
17
+
18
+ # Human readable error message.
19
+ #
20
+ # @return String
21
+ def to_s
22
+ "The field #{@field} is missing for #{@record.class}"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,2 @@
1
+ require 'spook_and_pay/providers/base'
2
+ require 'spook_and_pay/providers/braintree'
@@ -0,0 +1,203 @@
1
+ module SpookAndPay
2
+ module Providers
3
+ # The abstract class from which other Provider classes should inherit. This
4
+ # class is intended to behave more as a template than anything else. It
5
+ # provides very little in the way of actual implementation.
6
+ #
7
+ # To implement a provider all of the public methods of this class —
8
+ # excluding #initialize — must be implemented.
9
+ #
10
+ # Some features may not be supported by a provider, in which case the
11
+ # `NotSupportedError` should be raised in lieu of a real implementation.
12
+ class Base
13
+ # A hash which maps between the fields for a credit card and the actual
14
+ # form field names required by the provider.
15
+ #
16
+ # It should be over-ridden per provider.
17
+ FORM_FIELD_NAMES = {}.freeze
18
+
19
+ # An error used when validating the contents of an options hash. Since
20
+ # many of the methods on the provider classes take additional arguments
21
+ # as a Hash, it's important to make sure we give good errors when they
22
+ # are missing.
23
+ class InvalidOptionsError < StandardError
24
+ def initialize(errors)
25
+ @errors = errors
26
+ end
27
+
28
+ def to_s
29
+ "You have missed, or provided invalid options"
30
+ end
31
+ end
32
+
33
+ # An error for indicating actions that are not supported by a particular
34
+ # provider. In general, it will be the Base subclasses that define thier
35
+ # own versions of the the method that throw this error.
36
+ class NotSupportedError < StandardError
37
+ def to_s
38
+ "This action is not supported by this provider."
39
+ end
40
+ end
41
+
42
+ # Basic attributes
43
+ attr_reader :environment, :config
44
+
45
+ # @param [:production, :development, :test] env
46
+ # @param Hash config
47
+ #
48
+ # @return nil
49
+ def initialize(env, config)
50
+ @environment = env
51
+ @config = config
52
+
53
+ nil
54
+ end
55
+
56
+ # Retrieves the payment method details from the provider's vault.
57
+ #
58
+ # @param String id
59
+ #
60
+ # @return [SpookAndPay::CreditCard, nil]
61
+ def credit_card(id)
62
+ raise NotImplementedError
63
+ end
64
+
65
+ # Retrieves a credit card from the provider based on the transaction
66
+ # or transaction id provided. Some providers may not support this action.
67
+ #
68
+ # @param [String, SpookAndPay::Transaction] transaction_or_id
69
+ # @return [SpookAndPay::CreditCard, nil]
70
+ def credit_card_from_transaction(transaction_or_id)
71
+ raise NotSupportedError
72
+ end
73
+
74
+ # Retrieves the transaction details from the provider's vault.
75
+ #
76
+ # @param String id
77
+ #
78
+ # @return [SpookAndPay::Transaction, nil]
79
+ def transaction(id)
80
+ raise NotImplementedError
81
+ end
82
+
83
+ # Returns a hash containing the details necessary for making a
84
+ # submission. If you know what you're doing, you can use this directly,
85
+ # but otherwise you should be using the form helpers.
86
+ #
87
+ # The details generated by this method are for submitting card details to
88
+ # the provider for storage. Billing etc has to be handled via a separate
89
+ # step after submission.
90
+ #
91
+ # The arguments for this are specific to each provider implementation,
92
+ # but they all return a Hash with the same keys, like so:
93
+ # {
94
+ # :url => "...",
95
+ # :hidden_fields => {...},
96
+ # :field_names => {...}
97
+ # }
98
+ #
99
+ # Where :url is the target URL, :hidden_fields should be embedded in a
100
+ # form as they are and :field_names provide the mapping between known
101
+ # keys like :number and :cvv to the input names required by the provider.
102
+ def prepare_payment_submission(*args)
103
+ raise NotImplementedError
104
+ end
105
+
106
+ # Confirms the submission of payment details to the provider.
107
+ #
108
+ # The arguments for this method are specific to a provider.
109
+ def confirm_payment_submission(*args)
110
+ raise NotImplementedError
111
+ end
112
+
113
+ # Captures funds that have been pre-authorized.
114
+ #
115
+ # This should not be called directly. Instead, use the #capture! method
116
+ # provided by a Transaction instance.
117
+ #
118
+ # @param [SpookAndPay::Transaction, String] id
119
+ # @return SpookAndPay::Result
120
+ def capture_transaction(id)
121
+ raise NotImplementedError
122
+ end
123
+
124
+ # Refunds the amount of money captured in a transaction.
125
+ #
126
+ # This should not be called directly. Instead, use the #refund! method
127
+ # provided by a Transaction instance.
128
+ #
129
+ # @param [SpookAndPay::Transaction, String] id
130
+ # @return SpookAndPay::Result
131
+ def refund_transaction(id)
132
+ raise NotImplementedError
133
+ end
134
+
135
+ # Voids an authorization.
136
+ #
137
+ # This should not be called directly. Instead, use the #void! method
138
+ # provided by a Transaction instance.
139
+ #
140
+ # @param [SpookAndPay::Transaction, String] id
141
+ # @return SpookAndPay::Result
142
+ # @api private
143
+ # @abstract Subclass to implement
144
+ def void_transaction(id)
145
+ raise NotImplementedError
146
+ end
147
+
148
+ # Authorizes a payment against a credit card
149
+ #
150
+ # This should not be called directly. Instead, use the #authorize! method
151
+ # provided by a CreditCard instance.
152
+ #
153
+ # @param [SpookAndPay::CreditCard, String] id
154
+ # @param [String, Numeric] amount
155
+ # @return SpookAndPay::Result
156
+ # @api private
157
+ # @abstract Subclass to implement
158
+ def authorize_via_credit_card(id, amount)
159
+ raise NotImplementedError
160
+ end
161
+
162
+ # Creates a purchase against a credit card.
163
+ #
164
+ # This should not be called directly. Instead, use the #purchase! method
165
+ # provided by a CreditCard instance.
166
+ #
167
+ # @param [SpookAndPay::CreditCard, String] id
168
+ # @param [String, Numeric] amount
169
+ # @return SpookAndPay::Result
170
+ # @api private
171
+ # @abstract Subclass to implement
172
+ def purchase_via_credit_card(id, amount)
173
+ raise NotImplementedError
174
+ end
175
+
176
+ # Removes payment details from the provider's vault.
177
+ #
178
+ # This should not be called directly. Instead, use the #delete! method
179
+ # provided by a CreditCard instance.
180
+ #
181
+ # @param [SpookAndPay::CreditCard, String] id
182
+ # @return SpookAndPay::Result
183
+ # @api private
184
+ # @abstract Subclass to implement
185
+ def delete_credit_card(id)
186
+ raise NotImplementedError
187
+ end
188
+
189
+ private
190
+
191
+ # Extracts a transaction ID from it's target.
192
+ #
193
+ # @param [SpookAndPay::Transaction, String]
194
+ # @return String
195
+ def transaction_id(id)
196
+ case id
197
+ when SpookAndPay::Transaction then id.id
198
+ else id
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,277 @@
1
+ module SpookAndPay
2
+ module Providers
3
+ class Braintree < Base
4
+ FORM_FIELD_NAMES = {
5
+ :name => "transaction[credit_card][cardholder_name]",
6
+ :number => "transaction[credit_card][number]",
7
+ :expiration_month => "transaction[credit_card][expiration_month]",
8
+ :expiration_year => "transaction[credit_card][expiration_year]",
9
+ :cvv => "transaction[credit_card][cvv]"
10
+ }.freeze
11
+
12
+ attr_reader :adapter
13
+
14
+ # @param Hash config
15
+ # @option config String :merchant_id
16
+ # @option config String :public_key
17
+ # @option config String :private_key
18
+ def initialize(env, config)
19
+ @adapter = SpookAndPay::Adapters::Braintree.new(
20
+ env,
21
+ config[:merchant_id],
22
+ config[:public_key],
23
+ config[:private_key]
24
+ )
25
+
26
+ super(env, config)
27
+ end
28
+
29
+ # Braintree specific version of this method. Can be used to either
30
+ # authorize a payment — and capture it later — or submit a payment for
31
+ # settlement immediately. This is done via the type param.
32
+ #
33
+ # Because Braintree accepts payment details and processes payment in a
34
+ # single step, this method must also be provided with an amount.
35
+ #
36
+ # @param [:purchase, :authorize] type
37
+ # @param String redirect_url
38
+ # @param [String, Numeric] amount
39
+ # @param Hash opts
40
+ # @option opts [true, false] :vault
41
+ # @return Hash
42
+ def prepare_payment_submission(type, redirect_url, amount, opts = {})
43
+ payload = {
44
+ :transaction => {:type => 'sale', :amount => amount},
45
+ :redirect_url => redirect_url
46
+ }
47
+
48
+ if opts[:vault]
49
+ (payload[:transaction][:options] ||= {})[:store_in_vault] = true
50
+ end
51
+
52
+ if type == :purchase
53
+ (payload[:transaction][:options] ||= {})[:submit_for_settlement] = true
54
+ end
55
+
56
+ {
57
+ :url => adapter.transparent_redirect_url,
58
+ :hidden_fields => {:tr_data => adapter.transaction_data(payload)},
59
+ :field_names => self.class::FORM_FIELD_NAMES
60
+ }
61
+ end
62
+
63
+ # Confirms the submission of payment details to the provider.
64
+ #
65
+ # @param String query_string
66
+ #
67
+ # @return SpookAndPay::Result
68
+ def confirm_payment_submission(query_string)
69
+ result = adapter.confirm(query_string)
70
+
71
+ case result
72
+ when ::Braintree::SuccessfulResult
73
+ SpookAndPay::Result.new(
74
+ true,
75
+ result,
76
+ :credit_card => extract_credit_card(result.transaction.credit_card_details, true, false),
77
+ :transaction => extract_transaction(result.transaction)
78
+ )
79
+ when ::Braintree::ErrorResult
80
+ SpookAndPay::Result.new(
81
+ false,
82
+ result,
83
+ :credit_card => extract_credit_card(result.params[:transaction][:credit_card], false, false),
84
+ :transaction => extract_transaction(result.params[:transaction]),
85
+ :errors => extract_errors(result)
86
+ )
87
+ end
88
+ end
89
+
90
+ def credit_card(id)
91
+ result = adapter.credit_card(id)
92
+ extract_credit_card(result, true, false) if result
93
+ end
94
+
95
+ def credit_card_from_transaction(id)
96
+ _id = id.is_a?(String) ? id : id.id
97
+ result = adapter.transaction(_id)
98
+ extract_credit_card(result.credit_card_details, true, false)
99
+ end
100
+
101
+ def transaction(id)
102
+ result = adapter.transaction(id)
103
+ extract_transaction(result) if result
104
+ end
105
+
106
+ def capture_transaction(id)
107
+ result = adapter.capture(transaction_id(id))
108
+ generate_result(result)
109
+ end
110
+
111
+ def refund_transaction(id)
112
+ result = adapter.refund(transaction_id(id))
113
+ generate_result(result)
114
+ end
115
+
116
+ def void_transaction(id)
117
+ result = adapter.void(transaction_id(id))
118
+ generate_result(result)
119
+ end
120
+
121
+ private
122
+
123
+ # Maps the error codes returned by Braintree to a triple of target, type
124
+ # and field used by the SubmissionError class.
125
+ #
126
+ # The key is the error code from Braintree. The first entry in the triple
127
+ # is the specific portion of the transaction that has the error. The
128
+ # second is the type of error and the third is the field — if any — it
129
+ # applies to.
130
+ ERROR_CODE_MAPPING = {
131
+ "81715" => [:credit_card, :invalid_number, :number],
132
+ "81725" => [:credit_card, :number_required, :number],
133
+ "81703" => [:credit_card, :type_not_accepted, :card_type],
134
+ "81716" => [:credit_card, :wrong_length, :number],
135
+ "81712" => [:credit_card, :invalid_expiration_month, :expiration_month],
136
+ "81713" => [:credit_card, :invalid_expiration_year, :expiration_year],
137
+ "81707" => [:credit_card, :invalid_cvv, :cvv],
138
+ "91507" => [:transaction, :cannot_capture, :status],
139
+ "91506" => [:transaction, :cannot_refund, :status],
140
+ "91504" => [:transaction, :cannot_void, :status]
141
+ }.freeze
142
+
143
+ # Extracts errors from the collection returned by Brain tree and coerces
144
+ # them into an array of SubmissionError.
145
+ #
146
+ # @param Braintree:ErrorResult result
147
+ # @return Array<SpookAndPay::Providers::Base::SubmissionError>
148
+ def extract_errors(result)
149
+ result.errors.map do |e|
150
+ mapping = ERROR_CODE_MAPPING[e.code]
151
+ if mapping
152
+ SubmissionError.new(*mapping, e)
153
+ else
154
+ SubmissionError.new(:unknown, :unknown, :unknown, e)
155
+ end
156
+ end
157
+ end
158
+
159
+ # A generic method for generating results on actions. It doesn't capture
160
+ # anything action specific i.e. it might need to be replaced later.
161
+ #
162
+ # @param [Braintree::SuccessfulResult, Braintree:ErrorResult] result
163
+ # @return SpookAndPay::Result
164
+ def generate_result(result)
165
+ case result
166
+ when ::Braintree::SuccessfulResult
167
+ SpookAndPay::Result.new(
168
+ true,
169
+ result,
170
+ :credit_card => extract_credit_card(result.transaction.credit_card_details, true, false),
171
+ :transaction => extract_transaction(result.transaction)
172
+ )
173
+ when ::Braintree::ErrorResult
174
+ SpookAndPay::Result.new(
175
+ false,
176
+ result,
177
+ :errors => extract_errors(result)
178
+ )
179
+ end
180
+ end
181
+
182
+ # Extracts credit card details from a payload extracted from a result.
183
+ # It could be either a Hash, Braintree::CreditCard or
184
+ # Braintree::Transaction::CreditCardDetails. BOO!
185
+ #
186
+ # @param [Hash, Braintree::CreditCard, Braintree::Transaction::CreditCardDetails] card
187
+ # @param [true, false] valid
188
+ # @param [true, false] expired
189
+ # @return SpookAndPay::CreditCard
190
+ #
191
+ # @todo figure out validity and expiry ourselves
192
+ def extract_credit_card(card, valid, expired)
193
+ opts = case card
194
+ when Hash
195
+ {
196
+ :token => card[:token],
197
+ :card_type => card[:card_type],
198
+ :number => card[:last_4],
199
+ :name => card[:cardholder_name],
200
+ :expiration_month => card[:expiration_month],
201
+ :expiration_year => card[:expiration_year]
202
+ }
203
+ else
204
+ {
205
+ :token => card.token,
206
+ :card_type => card.card_type,
207
+ :number => card.last_4,
208
+ :name => card.cardholder_name,
209
+ :expiration_month => card.expiration_month,
210
+ :expiration_year => card.expiration_year
211
+ }
212
+ end
213
+
214
+ SpookAndPay::CreditCard.new(self, opts.delete(:token), opts)
215
+ end
216
+
217
+ # Extracts transaction details from whatever payload is passed in. This
218
+ # might be Hash or a Braintree:Transaction.
219
+ #
220
+ # @param [Hash, Braintree::Transaction] result
221
+ # @param [true, false] successful
222
+ # @param Hash payload
223
+ # @return SpookAndPay::Transaction
224
+ #
225
+ # @todo Coerce type into what we know is valid
226
+ def extract_transaction(result, payload = {})
227
+ case result
228
+ when Hash
229
+ SpookAndPay::Transaction.new(
230
+ self,
231
+ result[:id],
232
+ coerce_transaction_status(result[:status]),
233
+ result,
234
+ :type => result[:type].to_sym,
235
+ :created_at => result[:created_at],
236
+ :amount => result[:amount]
237
+ )
238
+ else
239
+ SpookAndPay::Transaction.new(
240
+ self,
241
+ result.id,
242
+ coerce_transaction_status(result.status),
243
+ result,
244
+ :type => result.type.to_sym,
245
+ :created_at => result.created_at,
246
+ :amount => result.amount
247
+ )
248
+ end
249
+ end
250
+
251
+ # Coerces the status into a value expected by the Transaction class.
252
+ #
253
+ # @param [String, nil] status
254
+ # @return [String, nil]
255
+ def coerce_transaction_status(status)
256
+ case status
257
+ when 'submitted_for_settlement' then 'settling'
258
+ else status
259
+ end
260
+ end
261
+
262
+ # Based on the status of a transaction, make some determination of how of
263
+ # whether or not is is successful.
264
+ #
265
+ # @param String status
266
+ # @return [true, false]
267
+ #
268
+ # @todo Expand this to be more robust.
269
+ def coerce_transaction_success(status)
270
+ case status
271
+ when 'authorized' then true
272
+ else false
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,68 @@
1
+ module SpookAndPay
2
+ # A small convenience class which wraps any results coming back from a
3
+ # provider. This is never instanced directly, but instead instances are
4
+ # created by the Provider classes.
5
+ class Result
6
+ # Readers for the various portions of a result's payload. Depending on the
7
+ # type of request any of these may be nil.
8
+ attr_reader :transaction, :credit_card, :raw, :errors
9
+
10
+ # @param [true, false] successful
11
+ # @param Class raw
12
+ # @param Hash opts
13
+ def initialize(successful, raw, opts = {})
14
+ @successful = successful
15
+ @raw = raw
16
+ @transaction = opts[:transaction] if opts.has_key?(:transaction)
17
+ @credit_card = opts[:credit_card] if opts.has_key?(:credit_card)
18
+ @errors = opts[:errors] || []
19
+ end
20
+
21
+ # Checks to see if a transaction is present.
22
+ #
23
+ # @return [true, false]
24
+ def transaction?
25
+ !transaction.nil?
26
+ end
27
+
28
+ # Checks to see if a credit card is present.
29
+ #
30
+ # @return [true, false]
31
+ def credit_card?
32
+ !credit_card.nil?
33
+ end
34
+
35
+ # Checks to see if any errors are present.
36
+ #
37
+ # @return [true, false]
38
+ def errors?
39
+ !errors.empty?
40
+ end
41
+
42
+ # Collects errors for a specific target, keyed by field.
43
+ #
44
+ # @param Symbol target
45
+ # @return Hash
46
+ def errors_for(target)
47
+ errors.select{|e| e.target == target}.reduce({}) do |h, e|
48
+ h[e.field] ||= []
49
+ h[e.field] << e
50
+ h
51
+ end
52
+ end
53
+
54
+ # A nice alias for checking for success.
55
+ #
56
+ # @return [true, false]
57
+ def successful?
58
+ @successful
59
+ end
60
+
61
+ # A nice helper for checking for failure.
62
+ #
63
+ # @return [true, false]
64
+ def failure?
65
+ !@successful
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,66 @@
1
+ module SpookAndPay
2
+ # Class used to encapsulate the details of an error related to some
3
+ # interaction with the provider. It is generic in that it might apply to
4
+ # a specific part of the payload or it might capture more general details.
5
+ #
6
+ # It will also attempt translate errors into a human readable string. Where
7
+ # it cannot, it still exposes the raw results from the provider allowing
8
+ # debugging.
9
+ class SubmissionError
10
+ # A constant which defines the acceptable types of errors and which is
11
+ # also used to generate specific messages. Where the error is unknown,
12
+ # the consumer of this library will be directed to use the raw error
13
+ # generated by the underlying libraries.
14
+ ERROR_MESSAGES = {
15
+ :credit_card => {
16
+ :number_required => "number is required",
17
+ :invalid_number => "number is invalid",
18
+ :type_not_accepted => "card type is not accepted by this merchant",
19
+ :wrong_length => "number must be between 12 and 19 digits",
20
+ :invalid_expiration_month => "expiration month is invalid",
21
+ :invalid_expiration_year => "expiration year is invalid",
22
+ :invalid_cvv => "CVV must be three digits"
23
+ },
24
+ :transaction => {
25
+ :cannot_capture => "must be authorized in order to capture funds",
26
+ :cannot_refund => "must be settled in order to refund",
27
+ :cannot_void => "must be authorized or settled in order to void"
28
+ },
29
+ :unknown => {
30
+ :unknown => "please refer to the #raw attribute of this error"
31
+ }
32
+ }.freeze
33
+
34
+ # Basic attributes
35
+ attr_reader :error_type, :message, :target, :raw, :field
36
+
37
+ # Generates a new error. Based on the target and error type, it can
38
+ # generate the appropriate error messages or otherwise fall back.
39
+ #
40
+ # @param Symbol error_type
41
+ # @param Symbol target
42
+ # @param [Symbol, nil] field
43
+ # @param Class raw
44
+ def initialize(target, error_type, field, raw)
45
+ @error_type = error_type
46
+ @target = target
47
+ @field = field
48
+ @raw = raw
49
+ end
50
+
51
+ # Indicates if this is an error that we don't know anything about.
52
+ #
53
+ # @return [true, false]
54
+ def unknown?
55
+ @error_type == :unknown
56
+ end
57
+
58
+ # Generates a human readable error message based on the target and
59
+ # error type.
60
+ #
61
+ # @return String
62
+ def message
63
+ ERROR_MESSAGES[target][error_type]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,149 @@
1
+ module SpookAndPay
2
+ # A simple class representing an interaction with a provider. Each
3
+ # interaction has an ID, a type and may be successful or not. It is
4
+ # read-only.
5
+ class Transaction
6
+ # Basic attributes
7
+ attr_reader :provider, :id, :status, :raw
8
+
9
+ # Extra set of fields which may or may not be present depending on the
10
+ # provider.
11
+ FIELDS = [:created_at, :updated_at, :amount, :credit_card, :type].freeze
12
+ attr_reader *FIELDS
13
+
14
+ # The basic types for transactions.
15
+ TYPES = [:purchase, :authorize, :capture, :credit, :void].freeze
16
+
17
+ # Acceptable set of statuses.
18
+ STATUSES = [:authorized, :settling, :settled, :voided, :refunded, :gateway_rejected].freeze
19
+
20
+ # An error thrown when attempting to perform an action that is not allowed
21
+ # given a transaction's status.
22
+ class InvalidActionError < StandardError
23
+ # @param String id
24
+ # @param Symbol action
25
+ # @param Symbol status
26
+ def initialize(id, action, status)
27
+ @id = id
28
+ @action = action
29
+ @status = status
30
+ end
31
+
32
+ # Human readable message.
33
+ #
34
+ # @return String
35
+ def to_s
36
+ "Cannot perform the action '#{@action}' for transaction '#{@id}' while in status '#{@status}'"
37
+ end
38
+ end
39
+
40
+ # As a bare minimum the transaction captures the transaction ID, it's
41
+ # status and the raw response from the provider. Optionally, it can receive
42
+ # other fields via the opts hash.
43
+ #
44
+ # @param SpookAndPay::Providers::Base provider
45
+ # @param String id
46
+ # @param [String, nil] status
47
+ # @param Class raw
48
+ # @param Hash opts
49
+ # @option opts SpookAndPay::CreditCard :credit_card
50
+ # @option opts Time :created_at
51
+ # @option opts Time :updated_at
52
+ # @option opts BigDecimal :amount
53
+ # @option opts String :type
54
+ def initialize(provider, id, status, raw, opts = {})
55
+ @provider = provider
56
+ @id = id
57
+ @status = status.to_sym if status
58
+ @raw = raw
59
+
60
+ FIELDS.each {|f| instance_variable_set(:"@#{f}", opts[f]) if opts.has_key?(f)}
61
+ end
62
+
63
+ # Implements value comparison i.e. if class and ID match, they are the
64
+ # same.
65
+ #
66
+ # @param Class other
67
+ # @return [true, false]
68
+ def ==(other)
69
+ other.is_a?(SpookAndPay::Transaction) and other.id == id
70
+ end
71
+
72
+ # A simple predicate to see if the payment has been settled.
73
+ #
74
+ # @return [true, false]
75
+ def settled?
76
+ status == :settled
77
+ end
78
+
79
+ # A simple predicate which indicates if the payment is in the process of
80
+ # being settled.
81
+ #
82
+ # @return [true, false]
83
+ def settling?
84
+ status == :settling
85
+ end
86
+
87
+ # A simple predicate to check if the payment has been authorized.
88
+ #
89
+ # @return [true, false]
90
+ def authorized?
91
+ status == :authorized
92
+ end
93
+
94
+ # A predicate for checking if a transaction can be refunded. Only true if
95
+ # the status is :settled
96
+ #
97
+ # @return [true, false]
98
+ def can_refund?
99
+ status == :settled
100
+ end
101
+
102
+ # A predicate for checking if a transaction can be captured. Only true if
103
+ # the status is :authorized
104
+ #
105
+ # @return [true, false]
106
+ def can_capture?
107
+ status == :authorized
108
+ end
109
+
110
+ # A predicate for checking if a transaction can be voided. Only true if
111
+ # the status is :authorized or :submitted_for_settlement
112
+ #
113
+ # @return [true, false]
114
+ def can_void?
115
+ status == :authorized or status == :settling
116
+ end
117
+
118
+ # Refunds the transaction. The related credit card will be credited for
119
+ # the amount captured. It will only succeed for purchases or captured
120
+ # authorizations.
121
+ #
122
+ # @return SpookAndPay::Result
123
+ # @raises InvalidActionError
124
+ def refund!
125
+ raise InvalidActionError.new(id, :refund, status) unless can_refund?
126
+ provider.refund_transaction(self)
127
+ end
128
+
129
+ # Captures an authorized transaction. Will only capture the amount
130
+ # authorized and will fail if the transaction is already captured.
131
+ #
132
+ # @return SpookAndPay::Result
133
+ # @raises InvalidActionError
134
+ def capture!
135
+ raise InvalidActionError.new(id, :capture, status) unless can_capture?
136
+ provider.capture_transaction(self)
137
+ end
138
+
139
+ # Voids a transaction. Can only be done when the transaction is in the
140
+ # authorized status. Otherwise it must be refunded.
141
+ #
142
+ # @return SpookAndPay::Result
143
+ # @raises InvalidActionError
144
+ def void!
145
+ raise InvalidActionError.new(id, :void, status) unless can_void?
146
+ provider.void_transaction(self)
147
+ end
148
+ end
149
+ end
metadata ADDED
@@ -0,0 +1,129 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spook_and_pay
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0.alpha
5
+ platform: ruby
6
+ authors:
7
+ - Luke Sutton
8
+ - Ben Hull
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-08-26 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: braintree
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - '='
19
+ - !ruby/object:Gem::Version
20
+ version: 2.25.0
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - '='
26
+ - !ruby/object:Gem::Version
27
+ version: 2.25.0
28
+ - !ruby/object:Gem::Dependency
29
+ name: rspec
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - '='
33
+ - !ruby/object:Gem::Version
34
+ version: 2.14.1
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - '='
40
+ - !ruby/object:Gem::Version
41
+ version: 2.14.1
42
+ - !ruby/object:Gem::Dependency
43
+ name: httparty
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - '='
47
+ - !ruby/object:Gem::Version
48
+ version: 0.11.0
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - '='
54
+ - !ruby/object:Gem::Version
55
+ version: 0.11.0
56
+ - !ruby/object:Gem::Dependency
57
+ name: rack
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - '='
61
+ - !ruby/object:Gem::Version
62
+ version: 1.5.2
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - '='
68
+ - !ruby/object:Gem::Version
69
+ version: 1.5.2
70
+ - !ruby/object:Gem::Dependency
71
+ name: debugger
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - '='
75
+ - !ruby/object:Gem::Version
76
+ version: 1.6.1
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - '='
82
+ - !ruby/object:Gem::Version
83
+ version: 1.6.1
84
+ description:
85
+ email:
86
+ - lukeandben@spookandpuff.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - lib/spook_and_pay/adapters/braintree.rb
92
+ - lib/spook_and_pay/adapters.rb
93
+ - lib/spook_and_pay/credit_card.rb
94
+ - lib/spook_and_pay/erroring_reader.rb
95
+ - lib/spook_and_pay/missing_value_error.rb
96
+ - lib/spook_and_pay/providers/base.rb
97
+ - lib/spook_and_pay/providers/braintree.rb
98
+ - lib/spook_and_pay/providers.rb
99
+ - lib/spook_and_pay/result.rb
100
+ - lib/spook_and_pay/submission_error.rb
101
+ - lib/spook_and_pay/transaction.rb
102
+ - lib/spook_and_pay.rb
103
+ - README.md
104
+ - LICENSE
105
+ homepage: http://spookandpuff.com
106
+ licenses: []
107
+ metadata: {}
108
+ post_install_message:
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - '>='
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - '>'
120
+ - !ruby/object:Gem::Version
121
+ version: 1.3.1
122
+ requirements: []
123
+ rubyforge_project:
124
+ rubygems_version: 2.0.3
125
+ signing_key:
126
+ specification_version: 4
127
+ summary: A library for handling online payments using services providing transparent
128
+ redirects.
129
+ test_files: []