xero_gateway 2.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 (75) hide show
  1. data/CHANGELOG.textile +51 -0
  2. data/LICENSE +14 -0
  3. data/README.textile +289 -0
  4. data/Rakefile +14 -0
  5. data/examples/oauth.rb +25 -0
  6. data/init.rb +1 -0
  7. data/lib/xero_gateway/account.rb +78 -0
  8. data/lib/xero_gateway/accounts_list.rb +77 -0
  9. data/lib/xero_gateway/address.rb +97 -0
  10. data/lib/xero_gateway/ca-certificates.crt +2560 -0
  11. data/lib/xero_gateway/contact.rb +206 -0
  12. data/lib/xero_gateway/currency.rb +56 -0
  13. data/lib/xero_gateway/dates.rb +25 -0
  14. data/lib/xero_gateway/error.rb +18 -0
  15. data/lib/xero_gateway/exceptions.rb +41 -0
  16. data/lib/xero_gateway/gateway.rb +363 -0
  17. data/lib/xero_gateway/http.rb +128 -0
  18. data/lib/xero_gateway/http_encoding_helper.rb +49 -0
  19. data/lib/xero_gateway/invoice.rb +278 -0
  20. data/lib/xero_gateway/line_item.rb +123 -0
  21. data/lib/xero_gateway/money.rb +16 -0
  22. data/lib/xero_gateway/oauth.rb +56 -0
  23. data/lib/xero_gateway/organisation.rb +61 -0
  24. data/lib/xero_gateway/payment.rb +40 -0
  25. data/lib/xero_gateway/phone.rb +77 -0
  26. data/lib/xero_gateway/private_app.rb +17 -0
  27. data/lib/xero_gateway/response.rb +37 -0
  28. data/lib/xero_gateway/tax_rate.rb +63 -0
  29. data/lib/xero_gateway/tracking_category.rb +62 -0
  30. data/lib/xero_gateway.rb +33 -0
  31. data/test/integration/accounts_list_test.rb +109 -0
  32. data/test/integration/create_contact_test.rb +66 -0
  33. data/test/integration/create_invoice_test.rb +49 -0
  34. data/test/integration/get_accounts_test.rb +23 -0
  35. data/test/integration/get_contact_test.rb +28 -0
  36. data/test/integration/get_contacts_test.rb +40 -0
  37. data/test/integration/get_currencies_test.rb +25 -0
  38. data/test/integration/get_invoice_test.rb +48 -0
  39. data/test/integration/get_invoices_test.rb +90 -0
  40. data/test/integration/get_organisation_test.rb +24 -0
  41. data/test/integration/get_tax_rates_test.rb +25 -0
  42. data/test/integration/get_tracking_categories_test.rb +26 -0
  43. data/test/integration/update_contact_test.rb +31 -0
  44. data/test/stub_responses/accounts.xml +1 -0
  45. data/test/stub_responses/api_exception.xml +153 -0
  46. data/test/stub_responses/contact.xml +1 -0
  47. data/test/stub_responses/contacts.xml +2189 -0
  48. data/test/stub_responses/create_invoice.xml +64 -0
  49. data/test/stub_responses/currencies.xml +16 -0
  50. data/test/stub_responses/invalid_api_key_error.xml +1 -0
  51. data/test/stub_responses/invalid_consumer_key +1 -0
  52. data/test/stub_responses/invalid_request_token +1 -0
  53. data/test/stub_responses/invoice.xml +1 -0
  54. data/test/stub_responses/invoice_not_found_error.xml +1 -0
  55. data/test/stub_responses/invoices.xml +1 -0
  56. data/test/stub_responses/organisation.xml +14 -0
  57. data/test/stub_responses/tax_rates.xml +52 -0
  58. data/test/stub_responses/token_expired +1 -0
  59. data/test/stub_responses/tracking_categories.xml +1 -0
  60. data/test/stub_responses/unknown_error.xml +1 -0
  61. data/test/test_helper.rb +81 -0
  62. data/test/unit/account_test.rb +34 -0
  63. data/test/unit/contact_test.rb +97 -0
  64. data/test/unit/currency_test.rb +31 -0
  65. data/test/unit/gateway_test.rb +79 -0
  66. data/test/unit/invoice_test.rb +302 -0
  67. data/test/unit/oauth_test.rb +110 -0
  68. data/test/unit/organisation_test.rb +34 -0
  69. data/test/unit/tax_rate_test.rb +38 -0
  70. data/test/unit/tracking_category_test.rb +30 -0
  71. data/test/xsd/README +2 -0
  72. data/test/xsd/create_contact.xsd +61 -0
  73. data/test/xsd/create_invoice.xsd +107 -0
  74. data/xero_gateway.gemspec +87 -0
  75. metadata +172 -0
@@ -0,0 +1,128 @@
1
+ module XeroGateway
2
+ module Http
3
+ OPEN_TIMEOUT = 10 unless defined? OPEN_TIMEOUT
4
+ READ_TIMEOUT = 60 unless defined? READ_TIMEOUT
5
+ ROOT_CA_FILE = File.join(File.dirname(__FILE__), 'ca-certificates.crt') unless defined? ROOT_CA_FILE
6
+
7
+ def http_get(client, url, extra_params = {})
8
+ http_request(client, :get, url, nil, extra_params)
9
+ end
10
+
11
+ def http_post(client, url, body, extra_params = {})
12
+ http_request(client, :post, url, body, extra_params)
13
+ end
14
+
15
+ def http_put(client, url, body, extra_params = {})
16
+ http_request(client, :put, url, body, extra_params)
17
+ end
18
+
19
+ private
20
+
21
+ def http_request(client, method, url, body, params = {})
22
+ # headers = {'Accept-Encoding' => 'gzip, deflate'}
23
+
24
+ headers = { 'charset' => 'utf-8' }
25
+
26
+ if method != :get
27
+ headers['Content-Type'] ||= "application/x-www-form-urlencoded"
28
+ end
29
+
30
+ # HAX. Xero completely misuse the If-Modified-Since HTTP header.
31
+ headers['If-Modified-Since'] = params.delete(:ModifiedAfter).utc.strftime("%Y-%m-%dT%H:%M:%S") if params[:ModifiedAfter]
32
+
33
+ if params.any?
34
+ url += "?" + params.map {|key,value| "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"}.join("&")
35
+ end
36
+
37
+ uri = URI.parse(url)
38
+
39
+ # # Only setup @cached_http once on first use as loading the CA file is quite expensive computationally.
40
+ # unless @cached_http && @cached_http.address == uri.host && @cached_http.port == uri.port
41
+ # @cached_http = Net::HTTP.new(uri.host, uri.port)
42
+ # @cached_http.open_timeout = OPEN_TIMEOUT
43
+ # @cached_http.read_timeout = READ_TIMEOUT
44
+ # @cached_http.use_ssl = true
45
+ #
46
+ # # Need to validate server's certificate against root certificate authority to prevent man-in-the-middle attacks.
47
+ # @cached_http.ca_file = ROOT_CA_FILE
48
+ # # http.verify_mode = OpenSSL::SSL::VERIFY_NONE
49
+ # @cached_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
50
+ # @cached_http.verify_depth = 5
51
+ # end
52
+
53
+ logger.info("\n== [#{Time.now.to_s}] XeroGateway Request: #{uri.request_uri} ") if self.logger
54
+
55
+ response = case method
56
+ when :get then client.get(uri.request_uri, headers)
57
+ when :post then client.post(uri.request_uri, { :xml => body }, headers)
58
+ when :put then client.put(uri.request_uri, { :xml => body }, headers)
59
+ end
60
+
61
+ if self.logger
62
+ logger.info("== [#{Time.now.to_s}] XeroGateway Response (#{response.code})")
63
+
64
+ unless response.code.to_i == 200
65
+ logger.info("== #{uri.request_uri} Response Body \n\n #{response.plain_body} \n == End Response Body")
66
+ end
67
+ end
68
+
69
+ case response.code.to_i
70
+ when 200
71
+ response.plain_body
72
+ when 400
73
+ handle_error!(response)
74
+ when 401
75
+ handle_oauth_error!(response)
76
+ when 404
77
+ handle_object_not_found!(response, url)
78
+ else
79
+ raise "Unknown response code: #{response.code.to_i}"
80
+ end
81
+ end
82
+
83
+ def handle_oauth_error!(response)
84
+ error_details = CGI.parse(response.plain_body)
85
+ description = error_details["oauth_problem_advice"].first
86
+
87
+ # see http://oauth.pbworks.com/ProblemReporting
88
+ # Xero only appears to return either token_expired or token_rejected
89
+ case (error_details["oauth_problem"].first)
90
+ when "token_expired" then raise OAuth::TokenExpired.new(description)
91
+ when "token_rejected" then raise OAuth::TokenInvalid.new(description)
92
+ end
93
+ end
94
+
95
+ def handle_error!(response)
96
+
97
+ raw_response = response.plain_body
98
+
99
+ # Xero Gateway API Exceptions *claim* to be UTF-16 encoded, but fail REXML/Iconv parsing...
100
+ # So let's ignore that :)
101
+ raw_response.gsub! '<?xml version="1.0" encoding="utf-16"?>', ''
102
+
103
+ doc = REXML::Document.new(raw_response, :ignore_whitespace_nodes => :all)
104
+
105
+ if doc.root.name == "ApiException"
106
+
107
+ raise ApiException.new(doc.root.elements["Type"].text,
108
+ doc.root.elements["Message"].text,
109
+ raw_response)
110
+
111
+ else
112
+
113
+ raise "Unparseable 400 Response: #{raw_response}"
114
+
115
+ end
116
+
117
+ end
118
+
119
+ def handle_object_not_found!(response, request_url)
120
+ if request_url =~ /Invoices/
121
+ raise InvoiceNotFoundError.new("Invoice not found in Xero.")
122
+ else
123
+ raise ObjectNotFound.new(request_url)
124
+ end
125
+ end
126
+
127
+ end
128
+ end
@@ -0,0 +1,49 @@
1
+ # Intended to extend the Net::HTTP response object
2
+ # and adds support for decoding gzip and deflate encoded pages
3
+ #
4
+ # Author: Jason Stirk <http://griffin.oobleyboo.com>
5
+ # Home: http://griffin.oobleyboo.com/projects/http_encoding_helper
6
+ # Created: 5 September 2007
7
+ # Last Updated: 23 November 2007
8
+ #
9
+ # Usage:
10
+ #
11
+ # require 'net/http'
12
+ # require 'http_encoding_helper'
13
+ # headers={'Accept-Encoding' => 'gzip, deflate' }
14
+ # http = Net::HTTP.new('griffin.oobleyboo.com', 80)
15
+ # http.start do |h|
16
+ # request = Net::HTTP::Get.new('/', headers)
17
+ # response = http.request(request)
18
+ # content=response.plain_body # Method from our library
19
+ # puts "Transferred: #{response.body.length} bytes"
20
+ # puts "Compression: #{response['content-encoding']}"
21
+ # puts "Extracted: #{response.plain_body.length} bytes"
22
+ # end
23
+ #
24
+
25
+ require 'zlib'
26
+ require 'stringio'
27
+
28
+ class Net::HTTPResponse
29
+ # Return the uncompressed content
30
+ def plain_body
31
+ encoding=self['content-encoding']
32
+ content=nil
33
+ if encoding then
34
+ case encoding
35
+ when 'gzip'
36
+ i=Zlib::GzipReader.new(StringIO.new(self.body))
37
+ content=i.read
38
+ when 'deflate'
39
+ i=Zlib::Inflate.new
40
+ content=i.inflate(self.body)
41
+ else
42
+ raise "Unknown encoding - #{encoding}"
43
+ end
44
+ else
45
+ content=self.body
46
+ end
47
+ return content
48
+ end
49
+ end
@@ -0,0 +1,278 @@
1
+ module XeroGateway
2
+ class Invoice
3
+ include Dates
4
+ include Money
5
+
6
+ class Error < RuntimeError; end
7
+ class NoGatewayError < Error; end
8
+ class InvalidLineItemError < Error; end
9
+
10
+ INVOICE_TYPE = {
11
+ 'ACCREC' => 'Accounts Receivable',
12
+ 'ACCPAY' => 'Accounts Payable'
13
+ } unless defined?(INVOICE_TYPE)
14
+
15
+ LINE_AMOUNT_TYPES = {
16
+ "Inclusive" => 'Invoice lines are inclusive tax',
17
+ "Exclusive" => 'Invoice lines are exclusive of tax (default)',
18
+ "NoTax" => 'Invoices lines have no tax'
19
+ } unless defined?(LINE_AMOUNT_TYPES)
20
+
21
+ INVOICE_STATUS = {
22
+ 'AUTHORISED' => 'Approved invoices awaiting payment',
23
+ 'DELETED' => 'Draft invoices that are deleted',
24
+ 'DRAFT' => 'Invoices saved as draft or entered via API',
25
+ 'PAID' => 'Invoices approved and fully paid',
26
+ 'SUBMITTED' => 'Invoices entered by an employee awaiting approval',
27
+ 'VOID' => 'Approved invoices that are voided'
28
+ } unless defined?(INVOICE_STATUS)
29
+
30
+ GUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ unless defined?(GUID_REGEX)
31
+
32
+ # Xero::Gateway associated with this invoice.
33
+ attr_accessor :gateway
34
+
35
+ # Any errors that occurred when the #valid? method called.
36
+ attr_reader :errors
37
+
38
+ # Represents whether the line_items have been downloaded when getting from GET /API.XRO/2.0/INVOICES
39
+ attr_accessor :line_items_downloaded
40
+
41
+ # All accessible fields
42
+ attr_accessor :invoice_id, :invoice_number, :invoice_type, :invoice_status, :date, :due_date, :reference, :line_amount_types, :currency_code, :line_items, :contact, :payments, :fully_paid_on, :amount_due, :amount_paid, :amount_credited
43
+
44
+
45
+ def initialize(params = {})
46
+ @errors ||= []
47
+ @payments ||= []
48
+
49
+ # Check if the line items have been downloaded.
50
+ @line_items_downloaded = (params.delete(:line_items_downloaded) == true)
51
+
52
+ params = {
53
+ :line_amount_types => "Inclusive"
54
+ }.merge(params)
55
+
56
+ params.each do |k,v|
57
+ self.send("#{k}=", v)
58
+ end
59
+
60
+ @line_items ||= []
61
+ end
62
+
63
+ # Validate the Address record according to what will be valid by the gateway.
64
+ #
65
+ # Usage:
66
+ # address.valid? # Returns true/false
67
+ #
68
+ # Additionally sets address.errors array to an array of field/error.
69
+ def valid?
70
+ @errors = []
71
+
72
+ if !invoice_id.nil? && invoice_id !~ GUID_REGEX
73
+ @errors << ['invoice_id', 'must be blank or a valid Xero GUID']
74
+ end
75
+
76
+ if invoice_status && !INVOICE_STATUS[invoice_status]
77
+ @errors << ['invoice_status', "must be one of #{INVOICE_STATUS.keys.join('/')}"]
78
+ end
79
+
80
+ if line_amount_types && !LINE_AMOUNT_TYPES[line_amount_types]
81
+ @errors << ['line_amount_types', "must be one of #{LINE_AMOUNT_TYPES.keys.join('/')}"]
82
+ end
83
+
84
+ unless date
85
+ @errors << ['invoice_date', "can't be blank"]
86
+ end
87
+
88
+ # Make sure contact is valid.
89
+ unless @contact && @contact.valid?
90
+ @errors << ['contact', 'is invalid']
91
+ end
92
+
93
+ # Make sure all line_items are valid.
94
+ unless line_items.all? { | line_item | line_item.valid? }
95
+ @errors << ['line_items', "at least one line item invalid"]
96
+ end
97
+
98
+ @errors.size == 0
99
+ end
100
+
101
+ # Helper method to create the associated contact object.
102
+ def build_contact(params = {})
103
+ self.contact = gateway ? gateway.build_contact(params) : Contact.new(params)
104
+ end
105
+
106
+ def contact
107
+ @contact ||= build_contact
108
+ end
109
+
110
+ # Helper method to create a new associated line_item.
111
+ # Usage:
112
+ # invoice.add_line_item({:description => "Bob's Widgets", :quantity => 1, :unit_amount => 120})
113
+ def add_line_item(params = {})
114
+ line_item = nil
115
+ case params
116
+ when Hash then line_item = LineItem.new(params)
117
+ when LineItem then line_item = params
118
+ else raise InvalidLineItemError
119
+ end
120
+
121
+ @line_items << line_item
122
+
123
+ line_item
124
+ end
125
+
126
+ # Deprecated (but API for setter remains).
127
+ #
128
+ # As sub_total must equal SUM(line_item.line_amount) for the API call to pass, this is now
129
+ # automatically calculated in the sub_total method.
130
+ def sub_total=(value)
131
+ end
132
+
133
+ # Calculate the sub_total as the SUM(line_item.line_amount).
134
+ def sub_total
135
+ line_items.inject(BigDecimal.new('0')) { | sum, line_item | sum + BigDecimal.new(line_item.line_amount.to_s) }
136
+ end
137
+
138
+ # Deprecated (but API for setter remains).
139
+ #
140
+ # As total_tax must equal SUM(line_item.tax_amount) for the API call to pass, this is now
141
+ # automatically calculated in the total_tax method.
142
+ def total_tax=(value)
143
+ end
144
+
145
+ # Calculate the total_tax as the SUM(line_item.tax_amount).
146
+ def total_tax
147
+ line_items.inject(BigDecimal.new('0')) { | sum, line_item | sum + BigDecimal.new(line_item.tax_amount.to_s) }
148
+ end
149
+
150
+ # Deprecated (but API for setter remains).
151
+ #
152
+ # As total must equal sub_total + total_tax for the API call to pass, this is now
153
+ # automatically calculated in the total method.
154
+ def total=(value)
155
+ end
156
+
157
+ # Calculate the toal as sub_total + total_tax.
158
+ def total
159
+ sub_total + total_tax
160
+ end
161
+
162
+ # Helper method to check if the invoice is accounts payable.
163
+ def accounts_payable?
164
+ invoice_type == 'ACCPAY'
165
+ end
166
+
167
+ # Helper method to check if the invoice is accounts receivable.
168
+ def accounts_receivable?
169
+ invoice_type == 'ACCREC'
170
+ end
171
+
172
+ # Whether or not the line_items have been downloaded (GET/invoices does not download line items).
173
+ def line_items_downloaded?
174
+ @line_items_downloaded
175
+ end
176
+
177
+ # If line items are not downloaded, then attempt a download now (if this record was found to begin with).
178
+ def line_items
179
+ if line_items_downloaded?
180
+ @line_items
181
+
182
+ # There is an invoice_is so we can assume this record was loaded from Xero.
183
+ # attempt to download the line_item records.
184
+ elsif invoice_id =~ GUID_REGEX
185
+ raise NoGatewayError unless @gateway
186
+
187
+ response = @gateway.get_invoice(invoice_id)
188
+ raise InvoiceNotFoundError, "Invoice with ID #{invoice_id} not found in Xero." unless response.success? && response.invoice.is_a?(XeroGateway::Invoice)
189
+
190
+ @line_items = response.invoice.line_items
191
+ @line_items_downloaded = true
192
+
193
+ @line_items
194
+
195
+ # Otherwise, this is a new invoice, so return the line_items reference.
196
+ else
197
+ @line_items
198
+ end
199
+ end
200
+
201
+ def ==(other)
202
+ ["invoice_number", "invoice_type", "invoice_status", "reference", "currency_code", "line_amount_types", "contact", "line_items"].each do |field|
203
+ return false if send(field) != other.send(field)
204
+ end
205
+
206
+ ["date", "due_date"].each do |field|
207
+ return false if send(field).to_s != other.send(field).to_s
208
+ end
209
+ return true
210
+ end
211
+
212
+ # General purpose createsave method.
213
+ # If contact_id and contact_number are nil then create, otherwise, attempt to save.
214
+ def save
215
+ create
216
+ end
217
+
218
+ # Creates this invoice record (using gateway.create_invoice) with the associated gateway.
219
+ # If no gateway set, raise a Xero::Invoice::NoGatewayError exception.
220
+ def create
221
+ raise NoGatewayError unless gateway
222
+ gateway.create_invoice(self)
223
+ end
224
+
225
+ # Alias create as save as this is currently the only write action.
226
+ alias_method :save, :create
227
+
228
+ def to_xml(b = Builder::XmlMarkup.new)
229
+ b.Invoice {
230
+ b.Type self.invoice_type
231
+ contact.to_xml(b)
232
+ b.Date Invoice.format_date(self.date || Date.today)
233
+ b.DueDate Invoice.format_date(self.due_date) if self.due_date
234
+ b.Status self.invoice_status if self.invoice_status
235
+ b.InvoiceNumber self.invoice_number if invoice_number
236
+ b.Reference self.reference if self.reference
237
+ b.CurrencyCode self.currency_code if self.currency_code
238
+ b.LineAmountTypes self.line_amount_types
239
+ b.LineItems {
240
+ self.line_items.each do |line_item|
241
+ line_item.to_xml(b)
242
+ end
243
+ }
244
+ }
245
+ end
246
+
247
+ #TODO UpdatedDateUTC
248
+ def self.from_xml(invoice_element, gateway = nil, options = {})
249
+ invoice = Invoice.new(options.merge({:gateway => gateway}))
250
+ invoice_element.children.each do |element|
251
+ case(element.name)
252
+ when "InvoiceID" then invoice.invoice_id = element.text
253
+ when "InvoiceNumber" then invoice.invoice_number = element.text
254
+ when "Type" then invoice.invoice_type = element.text
255
+ when "CurrencyCode" then invoice.currency_code = element.text
256
+ when "Type" then invoice.invoice_type = element.text
257
+ when "Contact" then invoice.contact = Contact.from_xml(element)
258
+ when "Date" then invoice.date = parse_date(element.text)
259
+ when "DueDate" then invoice.due_date = parse_date(element.text)
260
+ when "Status" then invoice.invoice_status = element.text
261
+ when "Reference" then invoice.reference = element.text
262
+ when "LineAmountTypes" then invoice.line_amount_types = element.text
263
+ when "LineItems" then element.children.each {|line_item| invoice.line_items_downloaded = true; invoice.line_items << LineItem.from_xml(line_item) }
264
+ when "SubTotal" then invoice.sub_total = BigDecimal.new(element.text)
265
+ when "TotalTax" then invoice.total_tax = BigDecimal.new(element.text)
266
+ when "Total" then invoice.total = BigDecimal.new(element.text)
267
+ when "InvoiceID" then invoice.invoice_id = element.text
268
+ when "InvoiceNumber" then invoice.invoice_number = element.text
269
+ when "Payments" then element.children.each { | payment | invoice.payments << Payment.from_xml(payment) }
270
+ when "AmountDue" then invoice.amount_due = BigDecimal.new(element.text)
271
+ when "AmountPaid" then invoice.amount_paid = BigDecimal.new(element.text)
272
+ when "AmountCredited" then invoice.amount_credited = BigDecimal.new(element.text)
273
+ end
274
+ end
275
+ invoice
276
+ end
277
+ end
278
+ end