solon 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE.txt ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (C) 2011 by Alastair Brunton, Daniel Turner
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.textile ADDED
@@ -0,0 +1,143 @@
1
+ h1. Solon - Sagepay VSP Server
2
+
3
+ h2. Introduction
4
+
5
+ Solon is a library for accessing the Sagepay VSP Server. Its patterns are loosely modeled on "ActiveMerchant":http://www.activemerchant.org/, for clarity (unfortunately AM doesn't support the VSP Server mode of operation). Solon was built for an application called "Woobius":https://www.woobius.com, but is released free of charge (and free of any warranties) to the community in the hope that it will be useful.
6
+
7
+ Using the Sagepay VSP Server enables you to not have to host payment pages on your own site. You initiate a transaction, then forward the user to the page returned by Sagepay, and the rest is handled off-site. This has many benefits, in particular not needing to implement credit card validation pages yourself, not needing to handle credit card numbers directly (which means that you're automatically PCI-DSS compliant), and not needing to deal with 3D-secure yourself.
8
+
9
+ Sagepay is an UK payment gateway. You can find more information on their VPS Server service "here":http://techsupport.Sagepay.com/vspservercustom.asp.
10
+
11
+ h2. Licence
12
+
13
+ The initial version of Solon was written from scratch by Daniel Tenner. It is loosely based on ActiveMerchant's patterns, but all code is original, and is licenced under the "Apache Licence":http://www.oss-watch.ac.uk/resources/apache2.xml.
14
+
15
+ As a user, this means you can effectively use Solon for whatever you want so long as you agree not to sue people about it.
16
+
17
+ As a contributor, it means that you licence to Solon any contributions that you make as per the Apache licence.
18
+
19
+ h2. Kicking the tyres
20
+
21
+ After setting up your IP address in the VSP Simulator, and obtaining a valid simulator vendor name, temporarily edit <code>simulate.rb</code> with your *vendor name* and run:
22
+
23
+ <pre>
24
+ ruby simulate.rb
25
+ </pre>
26
+
27
+ Everything should work. If it doesn't work, try asking me (swombat) for help on freenode, channel #flails.
28
+
29
+ Assuming everything worked fine, revert your change to simulate.rb.
30
+
31
+ h2. Usage
32
+
33
+ h3. Configuration
34
+
35
+ In each environment file, you want to do something like this:
36
+
37
+ <pre>
38
+ PEEVES_VENDOR = "woobius"
39
+ PEEVES_GATEWAY_MODE = :simulator
40
+ </pre>
41
+
42
+ The gateway mode can be set to <code>:simulator</code>, <code>:test</code>, or <code>:live</code>.
43
+
44
+ Then, in environment.rb, do:
45
+
46
+ <pre>
47
+ Solon::Config.vendor = PEEVES_VENDOR
48
+ Solon::Config.gateway_mode = PEEVES_GATEWAY_MODE
49
+ </pre>
50
+
51
+ h3. Making a request
52
+
53
+ As per <code>simulate.rb</code>:
54
+
55
+ <pre>
56
+ transaction_reference = Solon::UniqueId.generate("TEST")
57
+ customer_data = Solon::CustomerData.new(:surname => 'blah',
58
+ :firstnames => 'blah',
59
+ :address1 => 'blah',
60
+ :address2 => 'blah',
61
+ :city => 'blah',
62
+ :post_code => 'blah',
63
+ :country => 'gb',
64
+ :email => 'customer@email.com'
65
+ )
66
+
67
+ # Payment registration
68
+ payment_response = p.payment(Solon::Money.new(1000, "GBP"),
69
+ {
70
+ :transaction_reference => transaction_reference,
71
+ :description => "Test Transaction",
72
+ :notification_url => "http://callback.example.com/process_stuff",
73
+ :customer_data => { :billing => customer_data,
74
+ :delivery => customer_data,
75
+ :email => customer_data.email }
76
+ })
77
+ </pre>
78
+
79
+ This will register a payment and return a url that you should forward the user to.
80
+
81
+ Once the user has made the payment on the Sagepay VSP Server pages, you will receive a callback at the URL you defined as <code>:notification_url</code>. You should call something like the following on the parameters that you receive back:
82
+
83
+ <pre>
84
+ def record_response_params(params)
85
+ returning SolonGateway.parse_notification(params) do |response|
86
+ self.update_attributes(
87
+ :last_status => response.status,
88
+ :last_status_detail => response.last_status_detail,
89
+ :vps_transaction_id => response.vps_transaction_id,
90
+ :transaction_authorisation_number => response.transaction_authorisation_number,
91
+ :status_3d_secure => response.status_3d_secure,
92
+ :code_3d_secure => response.code_3d_secure
93
+ )
94
+ end
95
+ end
96
+ </pre>
97
+
98
+ In response to this, you must send back a success message or the transaction will eventually be cancelled by Sagepay:
99
+
100
+ <pre>
101
+ render :text => SolonGateway.response(SolonGateway::APPROVED, url, "Payment succeeded")
102
+ </pre>
103
+
104
+ Sagepay will then forward the user to <code>url</code>, where you can display a happy success page.
105
+
106
+ h3. Repeat transactions
107
+
108
+ Sagepay VSP Server wouldn't be worth much without repeat transactions. Here's an example of how to use them (this code sits on an Invoice class in Woobius):
109
+
110
+ <pre>
111
+ def pay_from(previous_invoice)
112
+ response = SolonGateway.new.repeat(Solon::Money.new(self.currency_amount, self.currency),
113
+ {
114
+ :transaction_reference => self.reference,
115
+ :description => self.description,
116
+ :related_transaction_reference => previous_invoice.reference,
117
+ :related_vps_transaction_id => previous_invoice.vps_transaction_id,
118
+ :related_security_key => previous_invoice.security_key,
119
+ :related_transaction_authorisation_number => previous_invoice.transaction_authorisation_number
120
+ })
121
+ self.update_attributes(
122
+ :last_status => response.status,
123
+ :last_status_detail => response.status_detail,
124
+ :vps_transaction_id => response.vps_transaction_id,
125
+ :security_key => response.security_key
126
+ )
127
+ if response.approved?
128
+ self.paid!
129
+ end
130
+
131
+ response
132
+ end
133
+ </pre>
134
+
135
+ h3. Keeping abreast of changes
136
+
137
+ Things might change. If you pull the latest version of Solon and something doesn't work, check the changelog: "CHANGES.textile":http://github.com/swombat/peeves/tree/master/CHANGES.textile.
138
+
139
+ h2. Contributing back
140
+
141
+ Please use the fork functionality on github to make a fork and then push back your changes to the fork queue. I will probably accept most useful changes, but it might take me a few days before I get around to it!
142
+
143
+ Thanks!
@@ -0,0 +1,15 @@
1
+ module Solon
2
+ class Basket
3
+ attr_accessor :items
4
+
5
+ def initialize(*args)
6
+ @items = args
7
+ end
8
+
9
+ def to_post_data
10
+ "#{@items.length}:" + @items.join(':')
11
+ end
12
+
13
+ alias_method :to_s, :to_post_data
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module Solon
2
+ class BasketItem
3
+ attr_accessor :description, :quantity, :unit_cost_without_tax, :tax_applied, :unit_cost_with_tax, :total_cost_with_tax
4
+
5
+ def to_post_data
6
+ "#{description}:#{quantity}:#{unit_cost_with_tax}:#{tax_applied}:#{unit_cost_with_tax}:#{total_cost_with_tax}"
7
+ end
8
+
9
+ alias_method :to_s, :to_post_data
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ module Solon
2
+ module Config
3
+ mattr_accessor :vendor
4
+ mattr_accessor :gateway_mode
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ module Solon
2
+ class CustomerData
3
+ # attr_accessor :address, :post_code, :name, :contact_number, :email
4
+ attr_accessor :surname, :firstnames, :address1, :address2, :city, :post_code, :country, :state, :phone, :email
5
+
6
+ def initialize(attrs = {})
7
+ attrs.each do |h,v|
8
+ send("#{h.to_s}=", v)
9
+ end
10
+ end
11
+
12
+ def [](arg)
13
+ send("#{arg}")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,4 @@
1
+ module Solon #:nodoc:
2
+ class Error < StandardError #:nodoc:
3
+ end
4
+ end
@@ -0,0 +1,10 @@
1
+ module Solon
2
+ class Money
3
+ def initialize(amount, currency)
4
+ @amount = amount.to_f
5
+ @currency = currency
6
+ end
7
+
8
+ attr_accessor :amount, :currency
9
+ end
10
+ end
@@ -0,0 +1,80 @@
1
+ require 'net/https'
2
+
3
+ module Solon
4
+ module Net
5
+
6
+ class ConnectionError < Solon::Error
7
+ end
8
+
9
+ class RetriableConnectionError < Solon::Error
10
+ end
11
+
12
+ class HttpsGateway
13
+
14
+ MAX_RETRIES = 3
15
+ OPEN_TIMEOUT = 60
16
+ READ_TIMEOUT = 60
17
+
18
+ def initialize(url, retry_safe=false, debug=false)
19
+ @url = url
20
+ @retry_safe = retry_safe
21
+ @debug = debug
22
+ end
23
+
24
+ def retry_safe?
25
+ @retry_safe
26
+ end
27
+
28
+ def debug?
29
+ @debug
30
+ end
31
+
32
+ def send(headers, data)
33
+ headers['Content-Type'] ||= "application/x-www-form-urlencoded"
34
+
35
+ uri = URI.parse(@url)
36
+
37
+ http = ::Net::HTTP.new(uri.host, uri.port)
38
+ http.open_timeout = OPEN_TIMEOUT
39
+ http.read_timeout = READ_TIMEOUT
40
+
41
+ http.set_debug_output $stdout if debug?
42
+
43
+ http.use_ssl = true
44
+
45
+ http.verify_mode = ::OpenSSL::SSL::VERIFY_NONE
46
+
47
+ retry_exceptions do
48
+ begin
49
+ http.post(uri.request_uri, data, headers).body
50
+ rescue EOFError => e
51
+ raise ConnectionError, "The remote server dropped the connection"
52
+ rescue Errno::ECONNRESET => e
53
+ raise ConnectionError, "The remote server reset the connection"
54
+ rescue Errno::ECONNREFUSED => e
55
+ raise RetriableConnectionError, "The remote server refused the connection"
56
+ rescue Timeout::Error, Errno::ETIMEDOUT => e
57
+ raise ConnectionError, "The connection to the remote server timed out"
58
+ end
59
+ end
60
+
61
+ end
62
+
63
+ def retry_exceptions
64
+ retries = MAX_RETRIES
65
+ begin
66
+ yield
67
+ rescue RetriableConnectionError => e
68
+ retries -= 1
69
+ puts e.inspect
70
+ retry unless retries.zero?
71
+ raise ConnectionError, e.message
72
+ rescue ConnectionError
73
+ retries -= 1
74
+ retry if retry_safe? && !retries.zero?
75
+ raise
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,11 @@
1
+ require 'cgi'
2
+
3
+ module Solon
4
+ class PostData < Hash
5
+ def to_post_data
6
+ collect { |key, value| "#{key}=#{CGI.escape(value.to_s)}" }.join("&")
7
+ end
8
+
9
+ alias_method :to_s, :to_post_data
10
+ end
11
+ end
@@ -0,0 +1,34 @@
1
+ module Solon
2
+ class SagepayCallback < SagepayResponse
3
+ def initialize(response)
4
+ @response = response
5
+ if @response.is_a?(String)
6
+ response.split("&").each do |line|
7
+ key, *value = line.split("=")
8
+ value = value.join("=")
9
+ self[key] = value
10
+ end
11
+ elsif @response.is_a?(Hash)
12
+ response.each do |key, value|
13
+ self[key] = value
14
+ end
15
+ else
16
+ raise Solon::Error, "Cannot parse response of type #{@response.class}"
17
+ end
18
+ end
19
+
20
+ def failed?
21
+ SolonGateway::FAILURES.include?(self.status.to_sym)
22
+ end
23
+
24
+ def error?
25
+ SolonGateway::ERRORS.include?(self.status.to_sym)
26
+ end
27
+
28
+ # Generic status checker method
29
+ def st(status_check)
30
+ status_check.to_s == self.status.to_s
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,84 @@
1
+ require 'cgi'
2
+ module Solon
3
+ class SagepayResponse
4
+ def initialize(response)
5
+ RAILS_DEFAULT_LOGGER.debug "Sagepay response: #{response}"
6
+ @response = response
7
+ if @response.is_a?(String)
8
+ response.split("\r\n").each do |line|
9
+ key, *value = line.split("=")
10
+ value = value.join("=")
11
+ self[key] = value
12
+ end
13
+ elsif @response.is_a?(Hash)
14
+ response.each do |key, value|
15
+ self[key] = value
16
+ end
17
+ else
18
+ raise Solon::Error, "Cannot parse response of type #{@response.class}"
19
+ end
20
+ end
21
+
22
+ def method_missing(id, *args)
23
+ id = id.to_s
24
+ @values ||= {}
25
+
26
+ case id[-1]
27
+ when 61: # :blah=
28
+ @values[id[0..-2].to_sym] = args[0]
29
+ when 63: # :blah?
30
+ @values.has_key?(id[0..-2].to_sym)
31
+ else # :blah
32
+ @values[id.to_sym]
33
+ end
34
+ end
35
+
36
+ def []=(key, value)
37
+ self.send("#{mapping[key] || key}=", CGI.unescape(value))
38
+ end
39
+
40
+ def [](key)
41
+ self.send("#{mapping[key] || key}")
42
+ end
43
+
44
+
45
+ # TODO: Make this work, currently fails all
46
+ def verify!
47
+ return self
48
+ md5 = Digest::MD5.new
49
+ md5 << "#{self.vps_transaction_id}#{self.transaction_reference}#{self.status}#{self.transaction_authorisation_number}" +
50
+ "#{Solon::Config.vendor}#{self.avs_cv2_result}#{self.security_key}#{self.address_result}#{self.post_code_result}" +
51
+ "#{self.cv2_result}#{self.gift_aid}#{self.status_3d_secure}#{self.code_3d_secure}"
52
+
53
+ raise Solon::Error, "MD5 appears to have been tampered with! (#{md5.hexdigest} != #{self.vps_signature})" unless md5.hexdigest == self.vps_signature
54
+
55
+ self
56
+ end
57
+
58
+ def approved?
59
+ self.status == SolonGateway::APPROVED
60
+ end
61
+
62
+ private
63
+ def mapping
64
+ {
65
+ "VPSProtocol" => :vps_protocol,
66
+ "Status" => :status,
67
+ "StatusDetail" => :status_detail,
68
+ "VPSTxId" => :vps_transaction_id,
69
+ "SecurityKey" => :security_key,
70
+ "NextURL" => :next_url,
71
+ "TxAuthNo" => :transaction_authorisation_number,
72
+ "AVSCV2" => :avs_cv2_result,
73
+ "AddressResult" => :address_result,
74
+ "PostCodeResult" => :post_code_result,
75
+ "CV2Result" => :cv2_result,
76
+ "VendorTxCode" => :transaction_reference,
77
+ "GiftAid" => :gift_aid,
78
+ "3DSecureStatus" => :status_3d_secure,
79
+ "CAVV" => :code_3d_secure,
80
+ "VPSSignature" => :vps_signature
81
+ }
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,51 @@
1
+ module Solon
2
+ module SagepayServices
3
+ BASE_URL = {
4
+ :simulator => 'https://test.sagepay.com/simulator/VSPServerGateway.asp',
5
+ :test => 'https://test.sagepay.com/gateway/service/',
6
+ :live => 'https://live.sagepay.com/gateway/service/'
7
+ }
8
+
9
+ SERVICE = {
10
+ :simulator => {
11
+ :payment => '?Service=VendorRegisterTx',
12
+ :deferred => '?Service=VendorRegisterTx',
13
+ :release => '?Service=VendorReleaseTx',
14
+ :abort => '?Service=VendorAbortTx',
15
+ :refund => '?Service=VendorRefundTx',
16
+ :repeat => '?Service=VendorRepeatTx',
17
+ :void => '?Service=VendorVoidTx',
18
+ :manual => lambda { raise Solon::Error, "MANUAL transactions not supported in simulator mode" },
19
+ :directrefund => lambda { raise Solon::Error, "DIRECTREFUND transactions not supported in simulator mode" },
20
+ :authorise => '?Service=VendorAuthoriseTx',
21
+ :cancel => '?Service=VendorCancelTx'
22
+ },
23
+ :test => {
24
+ :payment => 'vspserver-register.vsp',
25
+ :deferred => 'vspserver-register.vsp',
26
+ :release => 'release.vsp',
27
+ :abort => 'abort.vsp',
28
+ :refund => 'refund.vsp',
29
+ :repeat => 'repeat.vsp',
30
+ :void => 'void.vsp',
31
+ :manual => 'manual.vsp',
32
+ :directrefund => 'directrefund.vsp',
33
+ :authorise => 'authorise.vsp',
34
+ :cancel => 'cancel.vsp'
35
+ },
36
+ :live => {
37
+ :payment => 'vspserver-register.vsp',
38
+ :deferred => 'vspserver-register.vsp',
39
+ :release => 'release.vsp',
40
+ :abort => 'abort.vsp',
41
+ :refund => 'refund.vsp',
42
+ :repeat => 'repeat.vsp',
43
+ :void => 'void.vsp',
44
+ :manual => 'manual.vsp',
45
+ :directrefund => 'directrefund.vsp',
46
+ :authorise => 'authorise.vsp',
47
+ :cancel => 'cancel.vsp'
48
+ }
49
+ }
50
+ end
51
+ end
@@ -0,0 +1,7 @@
1
+ module Solon
2
+ class UniqueId
3
+ def self.generate(specific=nil)
4
+ "W-#{specific[0..14]}-#{Time.now.to_f}-#{'%05d' % rand(99999)}"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,397 @@
1
+ require 'cgi'
2
+
3
+ class SolonGateway
4
+ include Solon::SagepayServices
5
+
6
+ APPROVED = 'OK'
7
+ INVALID = 'INVALID'
8
+ NOTAUTHED = 'NOTAUTHED'
9
+ ABORT = 'ABORT'
10
+ REJECTED = 'REJECTED'
11
+ ERROR = 'ERROR'
12
+ ABORT_REPEATED = 'ABORT REPEATED'
13
+
14
+ FAILURES = [:NOTAUTHED, :ABORT, :REJECTED, :ABORT_REPEATED]
15
+ ERRORS = [:INVALID, :ERROR]
16
+
17
+
18
+ VPS_PROTOCOL = "2.23"
19
+
20
+ TRANSACTIONS = {
21
+ :payment => 'PAYMENT',
22
+ :authenticate => 'AUTHENTICATE',
23
+ :authorise => 'AUTHORISE',
24
+ :deferred => 'DEFERRED',
25
+ :refund => 'REFUND',
26
+ :release => 'RELEASE',
27
+ :repeat => 'REPEAT',
28
+ :void => 'VOID',
29
+ :cancel => 'CANCEL',
30
+ :abort => 'ABORT'
31
+ }
32
+
33
+ AVS_CVV_CODE = {
34
+ "NOTPROVIDED" => nil,
35
+ "NOTCHECKED" => 'X',
36
+ "MATCHED" => 'Y',
37
+ "NOTMATCHED" => 'N'
38
+ }
39
+
40
+ def initialize(mode=Solon::Config.gateway_mode)
41
+ @mode = mode
42
+ end
43
+
44
+ def debug=(value)
45
+ @no_debug = !value
46
+ end
47
+
48
+ def debug?
49
+ @mode == :simulator && !@no_debug
50
+ end
51
+
52
+ # Registers a payment so that the user can enter their details.
53
+ # Returns a URL that the user must be forwarded to to make the payment. If the process following this request is
54
+ # followed (i.e. redirect to next_url, user fills in their details, Sagepay notifies server, and server responds with OK)
55
+ # the settlement will go out the next day, automatically, for the full amount.
56
+ # Options:
57
+ # => transaction_reference (required, String(40))
58
+ # => description (required, String(100))
59
+ # => notification_url (required, String)
60
+ # => billing_data (optional, BillingData)
61
+ # => basket (optional, Basket)
62
+ # Response:
63
+ # => status
64
+ # => status_detail
65
+ # => vps_transaction_id
66
+ # => security_key
67
+ # => next_url
68
+ def payment(money, options)
69
+ log "payment", options
70
+
71
+ add_common TRANSACTIONS[:payment]
72
+ add_registration(money, options)
73
+ add_customer(options)
74
+ add_basket(options)
75
+
76
+ commit! :payment
77
+ end
78
+
79
+ # Registers a payment so that the user can enter their details.
80
+ # Returns a URL that the user must be forwarded to to make the payment. If the process following this request is
81
+ # followed (i.e. redirect to next_url, user fills in their details, Sagepay notifies server, and server responds with OK)
82
+ # the settlement will NOT go out the next day. Further "Authorise" requests are required before the user will be
83
+ # charged.
84
+ # Options:
85
+ # => transaction_reference (required, String(40))
86
+ # => description (required, String(100))
87
+ # => notification_url (required, String)
88
+ # => billing_data (optional, BillingData)
89
+ # => basket (optional, Basket)
90
+ # Response:
91
+ # => status
92
+ # => status_detail
93
+ # => vps_transaction_id
94
+ # => security_key
95
+ # => next_url
96
+ def authenticate(money, options)
97
+ log "authenticate", options
98
+
99
+ add_common(TRANSACTIONS[:authenticate])
100
+ add_registration(money, options)
101
+ add_customer(options)
102
+ add_basket(options)
103
+
104
+ commit! :payment
105
+ end
106
+
107
+ # Registers a payment so that the user can enter their details.
108
+ # Returns a URL that the user must be forwarded to to make the payment. If the process following this request is
109
+ # followed (i.e. redirect to next_url, user fills in their details, Sagepay notifies server, and server responds with OK)
110
+ # the settlement will NOT go out the next day, but a "shadow" will be placed on the account until the deferred payment
111
+ # is "Released" or "Aborted". Deferred payments are only supposed to be used to add a delay of up to 7 days, to allow
112
+ # cahrging at the point when the order is shipped.
113
+ # Options:
114
+ # => transaction_reference (required, String(40))
115
+ # => description (required, String(100))
116
+ # => notification_url (required, String)
117
+ # => billing_data (optional, BillingData)
118
+ # => basket (optional, Basket)
119
+ # Response:
120
+ # => status
121
+ # => status_detail
122
+ # => vps_transaction_id
123
+ # => security_key
124
+ # => next_url
125
+ def deferred(money, options)
126
+ log "deferred", options
127
+
128
+ add_common TRANSACTIONS[:deferred]
129
+ add_registration(money, options)
130
+ add_customer(options)
131
+ add_basket(options)
132
+
133
+ commit! :deferred
134
+ end
135
+
136
+ # Submits a repeat transaction.
137
+ # Options:
138
+ # => transaction_reference (required, String(40))
139
+ # => description (required, String(100))
140
+ # => related_transaction_reference (required, String(40))
141
+ # => related_vps_transaction_id (required, String(38))
142
+ # => related_security_key (required, String(10))
143
+ # => related_transaction_authorisation_number (required, Long Integer)
144
+ # Response:
145
+ # => status
146
+ # => status_detail
147
+ # => vps_transaction_id
148
+ # => transaction_authorisation_number
149
+ # => security_key
150
+ def repeat(money, options)
151
+ log "repeat", options
152
+
153
+ add_common(TRANSACTIONS[:repeat])
154
+ add_related(options)
155
+ add_registration(money, options)
156
+
157
+ commit! :repeat
158
+ end
159
+
160
+ # Authorises a previously authenticated transaction. This can be done multiple times, for amounts adding up to
161
+ # a maximum of 115% of the authenticated amount.
162
+ # => transaction_reference (required, String(40))
163
+ # => description (required, String(100))
164
+ # => related_transaction_reference (required, String(40))
165
+ # => related_vps_transaction_id (required, String(38))
166
+ # => related_security_key (required, String(10))
167
+ # Response:
168
+ # => status
169
+ # => status_detail
170
+ # => vps_transaction_id
171
+ # => transaction_authorisation_number
172
+ # => security_key
173
+ # => avs_cv2_result
174
+ # => address_result
175
+ # => post_code_result
176
+ # => cv2_result
177
+ def authorise(money, options)
178
+ log "authorise", options
179
+
180
+ add_common TRANSACTIONS[:authorise]
181
+ add_related(options)
182
+ add_registration(money, options)
183
+
184
+ commit! :authorise
185
+ end
186
+
187
+ # Cancels a previously authenticated transaction.
188
+ # Options:
189
+ # => transaction_reference (required, String(40))
190
+ # => vps_transaction_id (required, String(38))
191
+ # => security_key (required, String(10))
192
+ # Response:
193
+ # => status
194
+ # => status_detail
195
+ def cancel(options)
196
+ log "cancel", options
197
+
198
+ add_common TRANSACTIONS[:cancel]
199
+ add_post_processing(options)
200
+
201
+ commit! :cancel
202
+ end
203
+
204
+ # Releases (processes for payment/settlement) a DEFERRED or REPEATDEFERRED payment.
205
+ # Options:
206
+ # => transaction_reference (required, String(40))
207
+ # => vps_transaction_id (required, String(38))
208
+ # => security_key (required, String(10))
209
+ # => transaction_authorisation_number (required, Long Integer)
210
+ # Response:
211
+ # => status
212
+ # => status_detail
213
+ def release(money, options)
214
+ log "release", options
215
+
216
+ add_common TRANSACTIONS[:release]
217
+ add_post_processing(options)
218
+ @post["ReleaseAmount"] = "%.2f" % money.amount
219
+
220
+ commit! :release
221
+ end
222
+
223
+ # Voids a previously authorised payment (only possible after receiving the NotificationURL response).
224
+ # Options:
225
+ # => transaction_reference (required, String(40))
226
+ # => vps_transaction_id (required, String(38))
227
+ # => security_key (required, String(10))
228
+ # => transaction_authorisation_number (required, Long Integer)
229
+ # Response:
230
+ # => status
231
+ # => status_detail
232
+ def void(options)
233
+ log "void", options
234
+
235
+ add_common(TRANSACTIONS[:void])
236
+ add_post_processing(options)
237
+
238
+ commit! :void
239
+ end
240
+
241
+
242
+ # Aborts (cancels) a DEFERRED payment.
243
+ # Options:
244
+ # => transaction_reference (required, String(40))
245
+ # => vps_transaction_id (required, String(38))
246
+ # => security_key (required, String(10))
247
+ # => transaction_authorisation_number (required, Long Integer)
248
+ # Response:
249
+ # => status
250
+ # => status_detail
251
+ def abort(options)
252
+ log "abort", options
253
+
254
+ add_common(TRANSACTIONS[:abort])
255
+ add_post_processing(options)
256
+
257
+ commit! :abort
258
+ end
259
+
260
+ # Refunds a previously settled transaction.
261
+ # Options:
262
+ # => transaction_reference (required, String(40))
263
+ # => description (required, String(100))
264
+ # => related_transaction_reference (required, String(40))
265
+ # => related_vps_transaction_id (required, String(38))
266
+ # => related_security_key (required, String(10))
267
+ # => related_transaction_authorisation_number (required, Long Integer)
268
+ # Response:
269
+ # => status
270
+ # => status_detail
271
+ # => vps_transaction_id
272
+ # => transaction_authorisation_number
273
+ def refund(money, options)
274
+ log "refund", options
275
+
276
+ add_common(TRANSACTIONS[:refund])
277
+ add_related(options)
278
+ add_registration(money, options)
279
+
280
+ commit! :refund
281
+ end
282
+
283
+ def self.parse_notification(params)
284
+ Solon::SagepayResponse.new(params).verify!
285
+ end
286
+
287
+ def self.parse_callback(params)
288
+ Solon::SagepayCallback.new(params).verify!
289
+ end
290
+
291
+ def self.response(status, redirect_url, status_detail)
292
+ "Status=#{status}\r\n" +
293
+ "RedirectURL=#{redirect_url}\r\n" +
294
+ "StatusDetail=#{status_detail}"
295
+ end
296
+
297
+ private
298
+ def url_for(action)
299
+ BASE_URL[@mode] + SERVICE[@mode][action]
300
+ end
301
+
302
+ def commit!(action)
303
+ RAILS_DEFAULT_LOGGER.debug "Sending Sagepay post to #{url_for(action)}:\n" +
304
+ "Post: #{@post.inspect}\n" +
305
+ "Post data: #{@post.to_post_data}"
306
+ response = Solon::Net::HttpsGateway.new(url_for(action), true, debug?).send({}, @post.to_post_data)
307
+ Solon::SagepayResponse.new(response)
308
+ end
309
+
310
+ def add_common(type)
311
+ @post = Solon::PostData.new
312
+ @post["TxType"] = type
313
+ @post["VPSProtocol"] = VPS_PROTOCOL
314
+ @post["Vendor"] = Solon::Config.vendor
315
+ end
316
+
317
+ def add_post_processing(options)
318
+ @post["VendorTxCode"] = options[:transaction_reference][0..39]
319
+ @post["VPSTxId"] = options[:vps_transaction_id][0..37]
320
+ @post["SecurityKey"] = options[:security_key][0..9]
321
+ @post["TxAuthNo"] = options[:transaction_authorisation_number] unless options[:transaction_authorisation_number].nil?
322
+ end
323
+
324
+ def add_related(options)
325
+ @post["RelatedVendorTxCode"] = options[:related_transaction_reference][0..39]
326
+ @post["RelatedVPSTxId"] = options[:related_vps_transaction_id][0..37]
327
+ @post["RelatedSecurityKey"] = options[:related_security_key][0..9]
328
+ @post["RelatedTxAuthNo"] = options[:related_transaction_authorisation_number] unless options[:related_transaction_authorisation_number].nil?
329
+ end
330
+
331
+ def add_registration(money, options)
332
+ @post["VendorTxCode"] = options[:transaction_reference][0..39]
333
+ @post["Amount"] = "%.2f" % money.amount
334
+ @post["Currency"] = money.currency
335
+ @post["Description"] = options[:description][0..99]
336
+ @post["NotificationURL"] = options[:notification_url][0..254] unless options[:notification_url].nil?
337
+ end
338
+
339
+ def add_customer(options)
340
+ unless options[:customer_data].nil?
341
+ @post["CustomerEMail"] = options[:customer_data][:email]
342
+ add_billing(options[:customer_data])
343
+ add_delivery(options[:customer_data])
344
+ end
345
+ end
346
+
347
+ def add_billing(options)
348
+ unless options[:billing].nil?
349
+ # removed character limitation, as they should be enforced in view
350
+ @post["BillingSurname"] = options[:billing].surname
351
+ @post["BillingFirstnames"] = options[:billing].firstnames
352
+ @post["BillingAddress1"] = options[:billing].address1
353
+ @post["BillingAddress2"] = options[:billing].address2
354
+ @post["BillingCity"] = options[:billing].city
355
+ @post["BillingPostCode"] = options[:billing].post_code
356
+ @post["BillingCountry"] = options[:billing].country
357
+ @post["BillingPhone"] = options[:billing].phone
358
+ end
359
+ end
360
+
361
+ def add_delivery(options)
362
+ unless options[:delivery].nil?
363
+ # removed character limitation, as they should be enforced in view
364
+ @post["DeliverySurname"] = options[:delivery].surname
365
+ @post["DeliveryFirstnames"] = options[:delivery].firstnames
366
+ @post["DeliveryAddress1"] = options[:delivery].address1
367
+ @post["DeliveryAddress2"] = options[:delivery].address2
368
+ @post["DeliveryCity"] = options[:delivery].city
369
+ @post["DeliveryPostCode"] = options[:delivery].post_code
370
+ @post["DeliveryCountry"] = options[:delivery].country
371
+ @post["DeliveryPhone"] = options[:delivery].phone
372
+ end
373
+ end
374
+
375
+ def add_basket(options)
376
+ unless options[:basket].nil?
377
+ @post["Basket"] = options[:basket].to_post_data
378
+ end
379
+ end
380
+
381
+ def log(method, options)
382
+ RAILS_DEFAULT_LOGGER.debug "Called #{method} with options #{options.inspect}"
383
+ end
384
+
385
+ def requires!(hash, *params)
386
+ params.each do |param|
387
+ if param.is_a?(Array)
388
+ raise ArgumentError.new("Missing required parameter: #{param.first}") unless hash.has_key?(param.first)
389
+
390
+ valid_options = param[1..-1]
391
+ raise ArgumentError.new("Parameter: #{param.first} must be one of #{valid_options.to_sentence(:connector => 'or')}") unless valid_options.include?(hash[param.first])
392
+ else
393
+ raise ArgumentError.new("Missing required parameter: #{param}") unless hash.has_key?(param)
394
+ end
395
+ end
396
+ end
397
+ end
data/solon.gemspec ADDED
@@ -0,0 +1,18 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib/', __FILE__)
3
+ $:.unshift lib unless $:.include?(lib)
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "solon"
7
+ s.version = "0.0.1"
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Alastair Brunton"]
10
+ s.email = ["info@simplyexcited.co.uk"]
11
+ s.homepage = "http://github.com/pyrat/solon"
12
+ s.summary = "Interface to Sagepay payment processor"
13
+ s.description = "Interface for Sagepay VSP Server only."
14
+
15
+ s.required_rubygems_version = ">= 1.3.1"
16
+ s.require_path = 'lib'
17
+ s.files = `git ls-files`.split("\n")
18
+ end
@@ -0,0 +1,113 @@
1
+ require 'spec/spec_helper'
2
+
3
+ describe SolonGateway do
4
+
5
+ before(:each) do
6
+ @p = SolonGateway.new(:simulator)
7
+ @p.debug = false
8
+ end
9
+
10
+ describe "sending a payment request" do
11
+ before(:each) do
12
+ customer_data = Solon::CustomerData.new(:surname => 'blah',
13
+ :firstnames => 'blah',
14
+ :address1 => 'blah',
15
+ :address2 => 'blah',
16
+ :city => 'blah',
17
+ :post_code => 'blah',
18
+ :country => 'gb'
19
+ )
20
+
21
+ @response = @p.payment(Solon::Money.new(1000, "GBP"),
22
+ {
23
+ :transaction_reference => Solon::UniqueId.generate("TEST"),
24
+ :description => "Test Transaction",
25
+ :notification_url => "http://test.example.com",
26
+ :customer_data => {:billing => customer_data, :delivery =>customer_data} ,
27
+ })
28
+ end
29
+
30
+ it "should return a SagepayResponse" do
31
+ @response.is_a?(Solon::SagepayResponse).should be_true
32
+ end
33
+
34
+ it "should have a vps_transaction_id" do
35
+ @response.vps_transaction_id.should_not be_nil
36
+ end
37
+
38
+ it "should have a security_key" do
39
+ @response.security_key.should_not be_nil
40
+ end
41
+
42
+ it "should not have a transaction_authorisation_number" do
43
+ @response.transaction_authorisation_number.should be_nil
44
+ end
45
+ end
46
+
47
+ # commented out
48
+ # AUTHORISE can only be used after AUTHENTICATE, not PAYMENT
49
+ # describe "sending an authenticate request" do
50
+ # before(:each) do
51
+ # pending
52
+ # @transaction_reference = Solon::UniqueId.generate("TEST")
53
+ # @response = @p.authenticate Solon::Money.new(1000, "GBP"),
54
+ # {
55
+ # :transaction_reference => @transaction_reference,
56
+ # :description => "Test Transaction",
57
+ # :notification_url => "http://test.example.com"
58
+ # }
59
+ # end
60
+ #
61
+ # it "should return a SagepayResponse" do
62
+ # @response.is_a?(Solon::SagepayResponse).should be_true
63
+ # end
64
+ #
65
+ # it "should have a vps_transaction_id" do
66
+ # @response.vps_transaction_id.should_not be_nil
67
+ # end
68
+ #
69
+ # it "should have a security_key" do
70
+ # @response.security_key.should_not be_nil
71
+ # end
72
+ #
73
+ # it "should not have a transaction_authorisation_number" do
74
+ # @response.transaction_authorisation_number.should be_nil
75
+ # end
76
+ #
77
+ # it "should be cancellable" do
78
+ # @response2 = @p.cancel({
79
+ # :transaction_reference => @transaction_reference,
80
+ # :vps_transaction_id => @response.vps_transaction_id,
81
+ # :security_key => @response.security_key
82
+ # })
83
+ # @response2.status.should == SolonGateway::APPROVED
84
+ # end
85
+ # end
86
+
87
+ describe "receiving a notification" do
88
+ before(:each) do
89
+ params = {
90
+ "Status"=>"OK",
91
+ "TxType"=>"PAYMENT",
92
+ "VPSTxId"=>"{861A2DB0-E734-4DEB-8F8B-12C47B9ADF3E}",
93
+ "VendorTxCode"=>"W-TEST-1227524828.86576-59414",
94
+ "GiftAid"=>"0",
95
+ "AVSCV2"=>"ALL MATCH",
96
+ "TxAuthNo"=>"8661",
97
+ "VPSProtocol"=>"2.22",
98
+ "CAVV"=>"MNL2CYF4URE47IQNBI6DAH",
99
+ "3DSecureStatus"=>"OK",
100
+ "VPSSignature"=>"49A6FA9FE0631919D9B1E72ACE57584D",
101
+ "CV2Result"=>"MATCHED",
102
+ "PostCodeResult"=>"MATCHED",
103
+ "AddressResult"=>"MATCHED"
104
+ }
105
+ @result = SolonGateway.parse_notification(params)
106
+ end
107
+
108
+ it "should be able to parse the notification into a Sagepay Response" do
109
+ @result.is_a?(Solon::SagepayResponse).should be_true
110
+ end
111
+ end
112
+
113
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1,6 @@
1
+ --colour
2
+ --format
3
+ progress
4
+ --loadby
5
+ mtime
6
+ --reverse
@@ -0,0 +1,6 @@
1
+ require 'rubygems'
2
+ require 'activesupport'
3
+
4
+ $LOAD_PATH << File.dirname(__FILE__) + "/../lib/"
5
+
6
+ ActiveSupport::Dependencies.load_paths = $LOAD_PATH
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solon
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Alastair Brunton
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-08-14 00:00:00 +02:00
19
+ default_executable:
20
+ dependencies: []
21
+
22
+ description: Interface for Sagepay VSP Server only.
23
+ email:
24
+ - info@simplyexcited.co.uk
25
+ executables: []
26
+
27
+ extensions: []
28
+
29
+ extra_rdoc_files: []
30
+
31
+ files:
32
+ - LICENSE.txt
33
+ - README.textile
34
+ - lib/solon/basket.rb
35
+ - lib/solon/basket_item.rb
36
+ - lib/solon/config.rb
37
+ - lib/solon/customer_data.rb
38
+ - lib/solon/error.rb
39
+ - lib/solon/money.rb
40
+ - lib/solon/net/https_gateway.rb
41
+ - lib/solon/post_data.rb
42
+ - lib/solon/protx_callback.rb
43
+ - lib/solon/protx_response.rb
44
+ - lib/solon/protx_services.rb
45
+ - lib/solon/unique_id.rb
46
+ - lib/solon_gateway.rb
47
+ - solon.gemspec
48
+ - spec/solon_gateway_spec.rb
49
+ - spec/spec.opts
50
+ - spec/spec_helper.rb
51
+ has_rdoc: true
52
+ homepage: http://github.com/pyrat/solon
53
+ licenses: []
54
+
55
+ post_install_message:
56
+ rdoc_options: []
57
+
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ hash: 3
66
+ segments:
67
+ - 0
68
+ version: "0"
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ none: false
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ hash: 25
75
+ segments:
76
+ - 1
77
+ - 3
78
+ - 1
79
+ version: 1.3.1
80
+ requirements: []
81
+
82
+ rubyforge_project:
83
+ rubygems_version: 1.3.7
84
+ signing_key:
85
+ specification_version: 3
86
+ summary: Interface to Sagepay payment processor
87
+ test_files: []
88
+