spook_and_pay 0.2.0.alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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: []