docdata 0.0.1 → 0.0.2

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