docdata 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.coveralls.yml +2 -0
  3. data/.gitignore +4 -1
  4. data/.travis.yml +10 -0
  5. data/LICENSE +1 -1
  6. data/README.md +173 -7
  7. data/Rakefile +8 -0
  8. data/docdata.gemspec +18 -11
  9. data/lib/docdata.rb +74 -1
  10. data/lib/docdata/bank.rb +27 -0
  11. data/lib/docdata/config.rb +41 -0
  12. data/lib/docdata/docdata_error.rb +8 -0
  13. data/lib/docdata/engine.rb +13 -0
  14. data/lib/docdata/ideal.rb +40 -0
  15. data/lib/docdata/line_item.rb +99 -0
  16. data/lib/docdata/payment.rb +196 -0
  17. data/lib/docdata/response.rb +173 -0
  18. data/lib/docdata/shopper.rb +112 -0
  19. data/lib/docdata/version.rb +1 -1
  20. data/lib/docdata/xml/bank-list.xml +39 -0
  21. data/lib/docdata/xml/cancel.xml.erb +9 -0
  22. data/lib/docdata/xml/create.xml.erb +98 -0
  23. data/lib/docdata/xml/start.xml.erb +67 -0
  24. data/lib/docdata/xml/status.xml.erb +9 -0
  25. data/php-example/create.xml.erb +140 -0
  26. data/php-example/index.html +78 -0
  27. data/php-example/process.php +182 -0
  28. data/php-example/return.php +36 -0
  29. data/php-example/soap.rb +21 -0
  30. data/spec/config_spec.rb +53 -0
  31. data/spec/ideal_spec.rb +19 -0
  32. data/spec/line_item_spec.rb +55 -0
  33. data/spec/payment_spec.rb +162 -0
  34. data/spec/response_spec.rb +206 -0
  35. data/spec/shopper_spec.rb +50 -0
  36. data/spec/spec_helper.rb +36 -0
  37. data/spec/xml/status-canceled-creditcard.xml +34 -0
  38. data/spec/xml/status-canceled-ideal.xml +29 -0
  39. data/spec/xml/status-new.xml +20 -0
  40. data/spec/xml/status-paid-creditcard.xml +33 -0
  41. data/spec/xml/status-paid-ideal.xml +33 -0
  42. data/spec/xml/status-paid-sofort.xml +33 -0
  43. metadata +145 -13
  44. data/LICENSE.txt +0 -22
@@ -0,0 +1,8 @@
1
+ class DocdataError < StandardError
2
+ attr_reader :object
3
+
4
+
5
+ def initialize(object)
6
+ @object = object
7
+ end
8
+ end
@@ -0,0 +1,13 @@
1
+ module Docdata
2
+ #
3
+ # Simpel extend on the +Rails::Engine+ to add support for a new config section within
4
+ # the environment configs
5
+ #
6
+ # @example default
7
+ # # /config/environments/development.rb
8
+ # config.ideal_mollie.partner_id = 123456
9
+ #
10
+ class Engine < Rails::Engine
11
+ config.docdata = Docdata
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ module Docdata
2
+ #
3
+ # This class bundles all the needed logic and methods for IDEAL specific stuff.
4
+ #
5
+ class Ideal
6
+
7
+ #
8
+ # List of supported banks.
9
+ #
10
+ # @visibility public
11
+ #
12
+ # @example
13
+ # Docdata.banks
14
+ #
15
+ # For the lack of an available list of banks by Docdata,
16
+ # this gem uses the list provided by competitor Mollie.
17
+ #
18
+ # @return [Array<Docdata::Ideal>] list of supported +Bank+'s.
19
+ def self.banks
20
+ begin
21
+ @source ||= open('https://secure.mollie.nl/xml/ideal?a=banklist')
22
+ rescue
23
+ # in case the mollie API isn't available
24
+ # use the cached version (august 2014) of the XML file
25
+ @source = open("#{File.dirname(__FILE__)}/xml/bank-list.xml")
26
+ end
27
+ @response ||= Nokogiri::XML(@source)
28
+ @list = []
29
+ @response.xpath("//bank").each do |b|
30
+ bank = Docdata::Bank.new(
31
+ id: b.xpath("bank_id").first.content,
32
+ name: b.xpath("bank_name").first.content
33
+ )
34
+ @list << bank
35
+ end
36
+ return @list
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,99 @@
1
+ module Docdata
2
+
3
+
4
+ # Creates a validator
5
+ class LineItemValidator
6
+ include Veto.validator
7
+
8
+ validates :name, presence: true
9
+ validates :quantity, presence: true, integer: true
10
+ validates :price_per_unit, presence: true, integer: true
11
+ validates :description, presence: true
12
+ validates :code, presence: true
13
+ end
14
+
15
+
16
+ #
17
+ # Object representing a "LineItem"
18
+ #
19
+ # @example
20
+ # LineItem.new({
21
+ # :name => "Ham and Eggs by dr. Seuss",
22
+ # :code => "EAN312313235",
23
+ # :quantity => 1,
24
+ # :description => "The famous childrens book",
25
+ # :image => "http://blogs.slj.com/afuse8production/files/2012/06/GreenEggsHam1.jpg",
26
+ # :price_per_unit => 1299,
27
+ # :vat_rate => 17.5,
28
+ # :vat_included => true
29
+ # })
30
+ # @note Warning: do not use this part of the gem, for it will break. Be warned!
31
+ class LineItem
32
+ attr_accessor :errors
33
+ attr_accessor :name
34
+ attr_accessor :code
35
+ attr_accessor :quantity
36
+ attr_accessor :unit_of_measure
37
+ attr_accessor :description
38
+ attr_accessor :image
39
+ attr_accessor :price_per_unit
40
+ attr_accessor :vat_rate
41
+ attr_accessor :vat_included
42
+
43
+ #
44
+ # Initializer to transform a +Hash+ into an LineItem object
45
+ #
46
+ # @param [Hash] args
47
+ def initialize(args=nil)
48
+ @unit_of_measure = "PCS"
49
+ @vat_rate = 0
50
+ @vat_included = true
51
+ return if args.nil?
52
+ args.each do |k,v|
53
+ instance_variable_set("@#{k}", v) unless v.nil?
54
+ end
55
+ end
56
+
57
+ # @return [Boolean] true/false, depending if this instanciated object is valid
58
+ def valid?
59
+ validator = LineItemValidator.new
60
+ validator.valid?(self)
61
+ end
62
+
63
+ # @return [String] the string that contains all the errors for this line_item
64
+ def error_message
65
+ "One of your line_items is invalid. Error messages: #{errors.full_messages.join(', ')}"
66
+ end
67
+
68
+ # @return [Integer] total price of this line item
69
+ def total_price
70
+ price_per_unit * quantity
71
+ end
72
+
73
+ def gross_amount
74
+ if vat_included
75
+ total_price
76
+ else
77
+ total_price + vat
78
+ end
79
+ end
80
+
81
+ def nett_amount
82
+ if vat_included
83
+ total_price - vat
84
+ else
85
+ total_vat
86
+ end
87
+ end
88
+
89
+ # @return [Integer] the total amount of VAT (in cents) that is applicable for this line item,
90
+ # based on the vat_rate, quantity and price_per_unit
91
+ def vat
92
+ if vat_included
93
+ ((gross_amount.to_f * "1.#{vat_rate.to_s.gsub('.','')}".to_f) - gross_amount) * -1
94
+ else
95
+ ((nett_amount.to_f * "1.#{vat_rate.to_s.gsub('.','')}".to_f) - nett_amount)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,196 @@
1
+ module Docdata
2
+
3
+ # Creates a validator
4
+ class PaymentValidator
5
+ include Veto.validator
6
+ validates :amount, presence: true, integer: true
7
+ validates :profile, presence: true
8
+ validates :currency, presence: true, format: /[A-Z]{3}/
9
+ validates :order_reference, presence: true
10
+ end
11
+
12
+
13
+
14
+ #
15
+ # Object representing a "WSDL" object with attributes provided by Docdata
16
+ #
17
+ # @example
18
+ # Payment.new({
19
+ # :amount => 2500,
20
+ # :currency => "EUR",
21
+ # :order_reference => "TJ123"
22
+ # :profile => "MyProfile"
23
+ # :shopper => @shopper
24
+ # })
25
+ #
26
+ # @return [Array] Errors
27
+ # @param :amount [Integer] The total price in cents
28
+ # @param :currency [String] ISO currency code (USD, EUR, GBP, etc.)
29
+ # @param :order_reference [String] A unique order reference
30
+ # @param :profile [String] The DocData payment profile (e.g. 'MyProfile')
31
+ # @param :description [String] Description for this payment
32
+ # @param :receipt_text [String] A receipt text
33
+ # @param :shopper [Docdata::Shopper] A shopper object (instance of Docdata::Shopper)
34
+ # @param :bank_id [String] (optional) in case you want to redirect the consumer
35
+ # directly to the bank page (iDeal), you can set the bank id ('0031' for ABN AMRO for example.)
36
+ # @param :prefered_payment_method [String] (optional) set a prefered payment method.
37
+ # any of: [IDEAL, AMAX, VISA, etc.]
38
+ # @param :line_items [Array] (optional) Array of objects of type Docdata::LineItem
39
+ # @param :default_act [Boolean] (optional) Should the redirect URL contain a default_act=true parameter?
40
+ #
41
+ class Payment
42
+ attr_accessor :errors
43
+ attr_accessor :amount
44
+ @@amount = "?"
45
+ attr_accessor :description
46
+ attr_accessor :receipt_text
47
+ attr_accessor :currency
48
+ attr_accessor :order_reference
49
+ attr_accessor :profile
50
+ attr_accessor :shopper
51
+ attr_accessor :bank_id
52
+ attr_accessor :prefered_payment_method
53
+ attr_accessor :line_items
54
+ attr_accessor :key
55
+ attr_accessor :default_act
56
+
57
+
58
+
59
+ #
60
+ # Initializer to transform a +Hash+ into an Payment object
61
+ #
62
+ # @param [Hash] args
63
+ def initialize(args=nil)
64
+ @line_items = []
65
+ return if args.nil?
66
+ args.each do |k,v|
67
+ instance_variable_set("@#{k}", v) unless v.nil?
68
+ end
69
+ end
70
+
71
+
72
+ # @return [Boolean] true/false, depending if this instanciated object is valid
73
+ def valid?
74
+ validator = PaymentValidator.new
75
+ validator.valid?(self)
76
+ end
77
+
78
+ #
79
+ # This is the most importent method. It uses all the attributes
80
+ # and performs a `create` action on Docdata Payments SOAP API.
81
+ # @return [Docdata::Response] response object with `key`, `message` and `success?` methods
82
+ #
83
+ def create
84
+ # if there are any line items, they should all be valid.
85
+ validate_line_items
86
+
87
+ # puts
88
+
89
+ # make the SOAP API call
90
+ response = Docdata.client.call(:create, xml: xml)
91
+ response_object = Docdata::Response.parse(:create, response)
92
+ if response_object.success?
93
+ self.key = response_object.key
94
+ end
95
+ return response_object
96
+ end
97
+
98
+ # @return [String] the xml to send in the SOAP API
99
+ def xml
100
+ xml_file = "#{File.dirname(__FILE__)}/xml/create.xml.erb"
101
+ template = File.read(xml_file)
102
+ namespace = OpenStruct.new(payment: self, shopper: shopper)
103
+ xml = ERB.new(template).result(namespace.instance_eval { binding })
104
+ end
105
+
106
+ # Initialize a Payment object with the key set
107
+ def self.find(api_key)
108
+ p = self.new(key: api_key)
109
+ if p.status.success
110
+ return p
111
+ else
112
+ raise DocdataError.new(p), p.status.message
113
+ end
114
+ end
115
+
116
+ #
117
+ # This is one of the other native SOAP API methods.
118
+ # @return [Docdata::Response]
119
+ def status
120
+ # read the xml template
121
+ xml_file = "#{File.dirname(__FILE__)}/xml/status.xml.erb"
122
+ template = File.read(xml_file)
123
+ namespace = OpenStruct.new(payment: self)
124
+ xml = ERB.new(template).result(namespace.instance_eval { binding })
125
+
126
+ # puts xml
127
+
128
+ response = Docdata.client.call(:status, xml: xml)
129
+ response_object = Docdata::Response.parse(:status, response)
130
+
131
+ return response_object # Docdata::Response
132
+ end
133
+
134
+ # @return [String] The URI where the consumer can be redirected to in order to pay
135
+ def redirect_url
136
+ url = {}
137
+
138
+ base_url = Docdata.return_url
139
+ if Docdata.test_mode
140
+ redirect_base_url = 'https://test.docdatapayments.com/ps/menu'
141
+ else
142
+ redirect_base_url = 'https://secure.docdatapayments.com/ps/menu'
143
+ end
144
+ url[:command] = "show_payment_cluster"
145
+ url[:payment_cluster_key] = key
146
+ url[:merchant_name] = Docdata.username
147
+ # only include return URL if present
148
+ if base_url.present?
149
+ url[:return_url_success] = "#{base_url}/success?key=#{url[:payment_cluster_key]}"
150
+ url[:return_url_pending] = "#{base_url}/pending?key=#{url[:payment_cluster_key]}"
151
+ url[:return_url_canceled] = "#{base_url}/canceled?key=#{url[:payment_cluster_key]}"
152
+ url[:return_url_error] = "#{base_url}/error?key=#{url[:payment_cluster_key]}"
153
+ end
154
+ url[:client_language] = shopper.language_code
155
+ if default_act
156
+ url[:default_act] = true
157
+ end
158
+ if bank_id.present?
159
+ url[:ideal_issuer_id] = bank_id
160
+ url[:default_pm] = "IDEAL"
161
+ end
162
+ params = URI.encode_www_form(url)
163
+ uri = "#{redirect_base_url}?#{params}"
164
+ end
165
+
166
+
167
+ private
168
+
169
+ # In case there are any line_items, validate them all and
170
+ # raise an error for the first invalid LineItem
171
+ def validate_line_items
172
+ if @line_items.any?
173
+ for line_item in @line_items
174
+ if line_item.valid?
175
+ # do nothing, this line_item seems okay
176
+ else
177
+ raise DocdataError.new(line_item), line_item.error_message
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ # @return [Hash] list of VAT-rates and there respective totals
184
+ def vat_rates
185
+ rates = {}
186
+ for item in @line_items
187
+ rates["vat_#{item.vat_rate.to_s}"] ||= {}
188
+ rates["vat_#{item.vat_rate.to_s}"][:rate] ||= item.vat_rate
189
+ rates["vat_#{item.vat_rate.to_s}"][:total] ||= 0
190
+ rates["vat_#{item.vat_rate.to_s}"][:total] += item.vat
191
+ end
192
+ return rates
193
+ end
194
+
195
+ end
196
+ end
@@ -0,0 +1,173 @@
1
+ module Docdata
2
+
3
+ #
4
+ # Object representing a "response" with attributes provided by Docdata
5
+ #
6
+ # @example
7
+ # :create_success=>{
8
+ # :success=>"Operation successful.",
9
+ # :key=>"A7B623A3A7DB5949316F82049450C3F3"
10
+ # }
11
+ class Response
12
+
13
+ # @return [String] Payment key for future correspondence about this transaction
14
+ attr_accessor :key
15
+ # @return [Boolean] true/false, depending of the API response
16
+ attr_accessor :success
17
+ @@success = false
18
+ alias_method :success?, :success
19
+
20
+
21
+
22
+ # @return [String] Response message from DocData
23
+ attr_accessor :message
24
+
25
+ # @return [Hash] The parsed report node of the reponse-xml
26
+ attr_accessor :report
27
+
28
+ # @return [String] The raw XML returned by the API
29
+ attr_accessor :xml
30
+
31
+
32
+ #
33
+ # Initializer to transform a +Hash+ into an Response object
34
+ #
35
+ # @param [Hash] args
36
+ def initialize(args=nil)
37
+ @report = {}
38
+ return if args.nil?
39
+ args.each do |k,v|
40
+ instance_variable_set("@#{k}", v) unless v.nil?
41
+ end
42
+ end
43
+
44
+
45
+ #
46
+ # Parses the returned response hash and turns it
47
+ # into a new Docdata::Response object
48
+ #
49
+ # @param [String] method_name (name of the method: create, start, cancel, etc.)
50
+ # @param [Hash] response
51
+ def self.parse(method_name, response)
52
+ body, xml = self.response_body(response)
53
+ if body["#{method_name}_response".to_sym] && body["#{method_name}_response".to_sym]["#{method_name}_error".to_sym]
54
+ raise DocdataError.new(response), body["#{method_name}_response".to_sym]["#{method_name}_error".to_sym][:error]
55
+ else
56
+ m = body["#{method_name}_response".to_sym]["#{method_name}_success".to_sym]
57
+ r = self.new(key: m[:key], message: m[:success], success: true)
58
+ r.xml = xml #save the raw xml
59
+ if m[:report]
60
+ r.report = m[:report]
61
+ end
62
+ return r
63
+ end
64
+ end
65
+
66
+ # @return [Hash] the body of the response. In the test environment, this uses
67
+ # plain XML files, in normal use, it uses a `Savon::Response`
68
+ def self.response_body(response)
69
+ if response.is_a?(File)
70
+ parser = Nori.new(:convert_tags_to => lambda { |tag| tag.snakecase.to_sym })
71
+ xml = response.read
72
+ body = parser.parse(xml).first.last.first.last
73
+ else
74
+ body = response.body.to_hash
75
+ xml = response.xml
76
+ end
77
+ return body, xml
78
+ end
79
+
80
+ methods = [:total_registered, :total_shopper_pending, :total_acquier_pending, :total_acquirer_approved, :total_captured, :total_refunded, :total_chargedback]
81
+ methods.each do |method|
82
+ define_method method do
83
+ report[:approximate_totals][method].to_i
84
+ end
85
+ end
86
+
87
+ # @return [String] the payment method of this transaction
88
+ def payment_method
89
+ if report[:payment]
90
+ report[:payment][:payment_method]
91
+ else
92
+ nil
93
+ end
94
+ end
95
+
96
+ # @return [String] the status string provided by the API. One of [AUTHORIZED, CANCELED]
97
+ def payment_status
98
+ report[:payment][:authorization][:status]
99
+ end
100
+
101
+ # @return [Boolean] true/false, depending wether this payment is considered paid.
102
+ # @note Docdata doesn't explicitly say 'paid' or 'not paid', this is a little bit a gray area.
103
+ # There are several approaches to determine if a payment is paid, some slow and safe, other quick and unreliable.
104
+ # The reason for this is that some payment methods have a much longer processing time. For each payment method
105
+ # a different 'paid'.
106
+ # @note This method is never 100% reliable. If you need to finetune this, please implement your own method, using
107
+ # the available data (total_captured, total_registered, etc.)
108
+ def paid
109
+ if payment_method
110
+ case payment_method
111
+ # ideal (dutch)
112
+ when "IDEAL"
113
+ (total_registered == total_captured) && (capture_status == "CAPTURED")
114
+ # creditcard
115
+ when "MASTERCARD", "VISA", "AMEX"
116
+ (total_registered == total_acquirer_approved)
117
+ # sofort überweisung (german)
118
+ when "SOFORT_UEBERWEISUNG"
119
+ (total_registered == total_acquirer_approved)
120
+ # fallback: if total_registered equals total_caputured,
121
+ # we can assume that this order is paid. No 100% guarantee.
122
+ else
123
+ total_registered == total_captured
124
+ end
125
+ else
126
+ false
127
+ end
128
+ end
129
+ alias_method :paid?, :paid
130
+
131
+ # @return [Boolean]
132
+ def authorized
133
+ payment_status == "AUTHORIZED"
134
+ end
135
+ alias_method :authorized?, :authorized
136
+
137
+ # @return [Boolean]
138
+ def canceled
139
+ payment_status == "CANCELED" || capture_status == "CANCELED"
140
+ end
141
+ alias_method :canceled?, :canceled
142
+
143
+ # @return [String] the status of the capture, if exists
144
+ def capture_status
145
+ report[:payment][:authorization][:capture][:status]
146
+ end
147
+
148
+ # @return [Integer] the caputred amount in cents
149
+ def amount
150
+ report[:payment][:authorization][:amount].to_i
151
+ end
152
+
153
+ # @return [String] the currency if this transaction
154
+ def currency
155
+ status_xml.xpath("//amount").first.attributes["currency"].value
156
+ end
157
+
158
+ # @return [Nokogiri::XML::Document] object
159
+ def doc
160
+ # remove returns and whitespaces between tags
161
+ xml_string = xml.gsub("\n", "").gsub(/>\s+</, "><")
162
+ # return Nokogiri::XML::Document
163
+ @doc ||= Nokogiri.XML(xml_string)
164
+ end
165
+
166
+ # @return [Nokogiri::XML::Document] object, containing only the status section
167
+ # @note This is a fix for Nokogiri's trouble finding xpath elements after 'xlmns' attribute in a node.
168
+ def status_xml
169
+ @status_xml ||= Nokogiri.XML(doc.xpath("//S:Body").first.children.first.children.first.to_xml)
170
+ end
171
+
172
+ end
173
+ end