xero_gateway-float 2.0.15

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 (71) hide show
  1. data/Gemfile +12 -0
  2. data/LICENSE +14 -0
  3. data/README.textile +357 -0
  4. data/Rakefile +14 -0
  5. data/examples/oauth.rb +25 -0
  6. data/examples/partner_app.rb +36 -0
  7. data/init.rb +1 -0
  8. data/lib/oauth/oauth_consumer.rb +14 -0
  9. data/lib/xero_gateway.rb +39 -0
  10. data/lib/xero_gateway/account.rb +95 -0
  11. data/lib/xero_gateway/accounts_list.rb +87 -0
  12. data/lib/xero_gateway/address.rb +96 -0
  13. data/lib/xero_gateway/bank_transaction.rb +178 -0
  14. data/lib/xero_gateway/ca-certificates.crt +2560 -0
  15. data/lib/xero_gateway/contact.rb +206 -0
  16. data/lib/xero_gateway/credit_note.rb +222 -0
  17. data/lib/xero_gateway/currency.rb +56 -0
  18. data/lib/xero_gateway/dates.rb +30 -0
  19. data/lib/xero_gateway/error.rb +18 -0
  20. data/lib/xero_gateway/exceptions.rb +46 -0
  21. data/lib/xero_gateway/gateway.rb +622 -0
  22. data/lib/xero_gateway/http.rb +138 -0
  23. data/lib/xero_gateway/http_encoding_helper.rb +49 -0
  24. data/lib/xero_gateway/invoice.rb +236 -0
  25. data/lib/xero_gateway/line_item.rb +125 -0
  26. data/lib/xero_gateway/line_item_calculations.rb +55 -0
  27. data/lib/xero_gateway/money.rb +16 -0
  28. data/lib/xero_gateway/oauth.rb +87 -0
  29. data/lib/xero_gateway/organisation.rb +75 -0
  30. data/lib/xero_gateway/partner_app.rb +30 -0
  31. data/lib/xero_gateway/payment.rb +40 -0
  32. data/lib/xero_gateway/phone.rb +77 -0
  33. data/lib/xero_gateway/private_app.rb +17 -0
  34. data/lib/xero_gateway/response.rb +41 -0
  35. data/lib/xero_gateway/tax_rate.rb +63 -0
  36. data/lib/xero_gateway/tracking_category.rb +87 -0
  37. data/test/integration/accounts_list_test.rb +109 -0
  38. data/test/integration/create_bank_transaction_test.rb +38 -0
  39. data/test/integration/create_contact_test.rb +66 -0
  40. data/test/integration/create_credit_note_test.rb +49 -0
  41. data/test/integration/create_invoice_test.rb +49 -0
  42. data/test/integration/get_accounts_test.rb +23 -0
  43. data/test/integration/get_bank_transaction_test.rb +51 -0
  44. data/test/integration/get_bank_transactions_test.rb +88 -0
  45. data/test/integration/get_contact_test.rb +28 -0
  46. data/test/integration/get_contacts_test.rb +40 -0
  47. data/test/integration/get_credit_note_test.rb +48 -0
  48. data/test/integration/get_credit_notes_test.rb +90 -0
  49. data/test/integration/get_currencies_test.rb +25 -0
  50. data/test/integration/get_invoice_test.rb +48 -0
  51. data/test/integration/get_invoices_test.rb +92 -0
  52. data/test/integration/get_organisation_test.rb +24 -0
  53. data/test/integration/get_tax_rates_test.rb +25 -0
  54. data/test/integration/get_tracking_categories_test.rb +27 -0
  55. data/test/integration/update_bank_transaction_test.rb +31 -0
  56. data/test/integration/update_contact_test.rb +31 -0
  57. data/test/integration/update_invoice_test.rb +31 -0
  58. data/test/test_helper.rb +179 -0
  59. data/test/unit/account_test.rb +47 -0
  60. data/test/unit/bank_transaction_test.rb +126 -0
  61. data/test/unit/contact_test.rb +97 -0
  62. data/test/unit/credit_note_test.rb +284 -0
  63. data/test/unit/currency_test.rb +31 -0
  64. data/test/unit/gateway_test.rb +119 -0
  65. data/test/unit/invoice_test.rb +326 -0
  66. data/test/unit/oauth_test.rb +116 -0
  67. data/test/unit/organisation_test.rb +38 -0
  68. data/test/unit/tax_rate_test.rb +38 -0
  69. data/test/unit/tracking_category_test.rb +52 -0
  70. data/xero_gateway.gemspec +15 -0
  71. metadata +164 -0
@@ -0,0 +1,138 @@
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
+ headers = { 'charset' => 'utf-8' }
24
+
25
+ if method != :get
26
+ headers['Content-Type'] ||= "application/x-www-form-urlencoded"
27
+ end
28
+
29
+ headers['Accept'] = params.delete(:accept_type) if params[:accept_type]
30
+
31
+ # HAX. Xero completely misuse the If-Modified-Since HTTP header.
32
+ headers['If-Modified-Since'] = params.delete(:ModifiedAfter).utc.strftime("%Y-%m-%dT%H:%M:%S") if params[:ModifiedAfter]
33
+
34
+ if params.any?
35
+ url += "?" + params.map {|key,value| "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"}.join("&")
36
+ end
37
+
38
+ uri = URI.parse(url)
39
+
40
+ # # Only setup @cached_http once on first use as loading the CA file is quite expensive computationally.
41
+ # unless @cached_http && @cached_http.address == uri.host && @cached_http.port == uri.port
42
+ # @cached_http = Net::HTTP.new(uri.host, uri.port)
43
+ # @cached_http.open_timeout = OPEN_TIMEOUT
44
+ # @cached_http.read_timeout = READ_TIMEOUT
45
+ # @cached_http.use_ssl = true
46
+ #
47
+ # # Need to validate server's certificate against root certificate authority to prevent man-in-the-middle attacks.
48
+ # @cached_http.ca_file = ROOT_CA_FILE
49
+ # # http.verify_mode = OpenSSL::SSL::VERIFY_NONE
50
+ # @cached_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
51
+ # @cached_http.verify_depth = 5
52
+ # end
53
+
54
+ logger.info("\n== [#{Time.now.to_s}] XeroGateway Request: #{uri.request_uri} ") if self.logger
55
+
56
+ response = case method
57
+ when :get then client.get(uri.request_uri, headers)
58
+ when :post then client.post(uri.request_uri, { :xml => body }, headers)
59
+ when :put then client.put(uri.request_uri, { :xml => body }, headers)
60
+ end
61
+
62
+ if self.logger
63
+ logger.info("== [#{Time.now.to_s}] XeroGateway Response (#{response.code})")
64
+
65
+ unless response.code.to_i == 200
66
+ logger.info("== #{uri.request_uri} Response Body \n\n #{response.plain_body} \n == End Response Body")
67
+ end
68
+ end
69
+
70
+ case response.code.to_i
71
+ when 200
72
+ response.plain_body
73
+ when 400
74
+ handle_error!(body, response)
75
+ when 401
76
+ handle_oauth_error!(response)
77
+ when 404
78
+ handle_object_not_found!(response, url)
79
+ when 503
80
+ handle_oauth_error!(response)
81
+ else
82
+ raise "Unknown response code: #{response.code.to_i}"
83
+ end
84
+ end
85
+
86
+ def handle_oauth_error!(response)
87
+ error_details = CGI.parse(response.plain_body)
88
+ description = error_details["oauth_problem_advice"].first
89
+
90
+ # see http://oauth.pbworks.com/ProblemReporting
91
+ # In addition to token_expired and token_rejected, Xero also returns
92
+ # 'rate limit exceeded' when more than 60 requests have been made in
93
+ # a second.
94
+ case (error_details["oauth_problem"].first)
95
+ when "token_expired" then raise OAuth::TokenExpired.new(description)
96
+ when "consumer_key_unknown" then raise OAuth::TokenInvalid.new(description)
97
+ when "token_rejected" then raise OAuth::TokenInvalid.new(description)
98
+ when "rate limit exceeded" then raise OAuth::RateLimitExceeded.new(description)
99
+ else raise OAuth::UnknownError.new(error_details["oauth_problem"].first + ':' + description)
100
+ end
101
+ end
102
+
103
+ def handle_error!(request_xml, response)
104
+
105
+ raw_response = response.plain_body
106
+
107
+ # Xero Gateway API Exceptions *claim* to be UTF-16 encoded, but fail REXML/Iconv parsing...
108
+ # So let's ignore that :)
109
+ raw_response.gsub! '<?xml version="1.0" encoding="utf-16"?>', ''
110
+
111
+ doc = REXML::Document.new(raw_response, :ignore_whitespace_nodes => :all)
112
+
113
+ if doc.root.name == "ApiException"
114
+
115
+ raise ApiException.new(doc.root.elements["Type"].text,
116
+ doc.root.elements["Message"].text,
117
+ request_xml,
118
+ raw_response)
119
+
120
+ else
121
+
122
+ raise "Unparseable 400 Response: #{raw_response}"
123
+
124
+ end
125
+
126
+ end
127
+
128
+ def handle_object_not_found!(response, request_url)
129
+ case(request_url)
130
+ when /Invoices/ then raise InvoiceNotFoundError.new("Invoice not found in Xero.")
131
+ when /BankTransactions/ then raise BankTransactionNotFoundError.new("Bank Transaction not found in Xero.")
132
+ when /CreditNotes/ then raise CreditNoteNotFoundError.new("Credit Note not found in Xero.")
133
+ else raise ObjectNotFound.new(request_url)
134
+ end
135
+ end
136
+
137
+ end
138
+ 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,236 @@
1
+ module XeroGateway
2
+ class Invoice
3
+ include Dates
4
+ include Money
5
+ include LineItemCalculations
6
+
7
+ class NoGatewayError < Error; end
8
+
9
+ INVOICE_TYPE = {
10
+ 'ACCREC' => 'Accounts Receivable',
11
+ 'ACCPAY' => 'Accounts Payable'
12
+ } unless defined?(INVOICE_TYPE)
13
+
14
+ LINE_AMOUNT_TYPES = {
15
+ "Inclusive" => 'Invoice lines are inclusive tax',
16
+ "Exclusive" => 'Invoice lines are exclusive of tax (default)',
17
+ "NoTax" => 'Invoices lines have no tax'
18
+ } unless defined?(LINE_AMOUNT_TYPES)
19
+
20
+ INVOICE_STATUS = {
21
+ 'AUTHORISED' => 'Approved invoices awaiting payment',
22
+ 'DELETED' => 'Draft invoices that are deleted',
23
+ 'DRAFT' => 'Invoices saved as draft or entered via API',
24
+ 'PAID' => 'Invoices approved and fully paid',
25
+ 'SUBMITTED' => 'Invoices entered by an employee awaiting approval',
26
+ 'VOID' => 'Approved invoices that are voided'
27
+ } unless defined?(INVOICE_STATUS)
28
+
29
+ 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)
30
+
31
+ # Xero::Gateway associated with this invoice.
32
+ attr_accessor :gateway
33
+
34
+ # Any errors that occurred when the #valid? method called.
35
+ attr_reader :errors
36
+
37
+ # Represents whether the line_items have been downloaded when getting from GET /API.XRO/2.0/INVOICES
38
+ attr_accessor :line_items_downloaded
39
+
40
+ # All accessible fields
41
+ 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, :sent_to_contact, :url, :sub_total, :total_tax, :total, :updated_date_utc
42
+
43
+
44
+ def initialize(params = {})
45
+ @errors ||= []
46
+ @payments ||= []
47
+
48
+ # Check if the line items have been downloaded.
49
+ @line_items_downloaded = (params.delete(:line_items_downloaded) == true)
50
+
51
+ params = {
52
+ :line_amount_types => "Exclusive"
53
+ }.merge(params)
54
+
55
+ params.each do |k,v|
56
+ self.send("#{k}=", v)
57
+ end
58
+
59
+ @line_items ||= []
60
+ end
61
+
62
+ # Validate the Address record according to what will be valid by the gateway.
63
+ #
64
+ # Usage:
65
+ # address.valid? # Returns true/false
66
+ #
67
+ # Additionally sets address.errors array to an array of field/error.
68
+ def valid?
69
+ @errors = []
70
+
71
+ if !invoice_id.nil? && invoice_id !~ GUID_REGEX
72
+ @errors << ['invoice_id', 'must be blank or a valid Xero GUID']
73
+ end
74
+
75
+ if invoice_status && !INVOICE_STATUS[invoice_status]
76
+ @errors << ['invoice_status', "must be one of #{INVOICE_STATUS.keys.join('/')}"]
77
+ end
78
+
79
+ if line_amount_types && !LINE_AMOUNT_TYPES[line_amount_types]
80
+ @errors << ['line_amount_types', "must be one of #{LINE_AMOUNT_TYPES.keys.join('/')}"]
81
+ end
82
+
83
+ unless date
84
+ @errors << ['invoice_date', "can't be blank"]
85
+ end
86
+
87
+ # Make sure contact is valid.
88
+ unless @contact && @contact.valid?
89
+ @errors << ['contact', 'is invalid']
90
+ end
91
+
92
+ # Make sure all line_items are valid.
93
+ unless line_items.all? { | line_item | line_item.valid? }
94
+ @errors << ['line_items', "at least one line item invalid"]
95
+ end
96
+
97
+ @errors.size == 0
98
+ end
99
+
100
+ # Helper method to create the associated contact object.
101
+ def build_contact(params = {})
102
+ self.contact = gateway ? gateway.build_contact(params) : Contact.new(params)
103
+ end
104
+
105
+ def contact
106
+ @contact ||= build_contact
107
+ end
108
+
109
+ # Helper method to check if the invoice is accounts payable.
110
+ def accounts_payable?
111
+ invoice_type == 'ACCPAY'
112
+ end
113
+
114
+ # Helper method to check if the invoice is accounts receivable.
115
+ def accounts_receivable?
116
+ invoice_type == 'ACCREC'
117
+ end
118
+
119
+ # Whether or not the line_items have been downloaded (GET/invoices does not download line items).
120
+ def line_items_downloaded?
121
+ @line_items_downloaded
122
+ end
123
+
124
+ # If line items are not downloaded, then attempt a download now (if this record was found to begin with).
125
+ def line_items
126
+ if line_items_downloaded?
127
+ @line_items
128
+
129
+ elsif invoice_id =~ GUID_REGEX && @gateway
130
+ # There is an invoice_id so we can assume this record was loaded from Xero.
131
+ # Let's attempt to download the line_item records (if there is a gateway)
132
+ response = @gateway.get_invoice(invoice_id)
133
+ raise InvoiceNotFoundError, "Invoice with ID #{invoice_id} not found in Xero." unless response.success? && response.invoice.is_a?(XeroGateway::Invoice)
134
+
135
+ @line_items = response.invoice.line_items
136
+ @line_items_downloaded = true
137
+
138
+ @line_items
139
+
140
+ # Otherwise, this is a new invoice, so return the line_items reference.
141
+ else
142
+ @line_items
143
+ end
144
+ end
145
+
146
+ def ==(other)
147
+ ["invoice_number", "invoice_type", "invoice_status", "reference", "currency_code", "line_amount_types", "contact", "line_items"].each do |field|
148
+ return false if send(field) != other.send(field)
149
+ end
150
+
151
+ ["date", "due_date"].each do |field|
152
+ return false if send(field).to_s != other.send(field).to_s
153
+ end
154
+ return true
155
+ end
156
+
157
+ # General purpose create/save method.
158
+ # If invoice_id is nil then create, otherwise, attempt to save.
159
+ def save
160
+ if invoice_id.nil?
161
+ create
162
+ else
163
+ update
164
+ end
165
+ end
166
+
167
+ # Creates this invoice record (using gateway.create_invoice) with the associated gateway.
168
+ # If no gateway set, raise a Xero::Invoice::NoGatewayError exception.
169
+ def create
170
+ raise NoGatewayError unless gateway
171
+ gateway.create_invoice(self)
172
+ end
173
+
174
+ # Updates this invoice record (using gateway.update_invoice) with the associated gateway.
175
+ # If no gateway set, raise a Xero::Invoice::NoGatewayError exception.
176
+ def update
177
+ raise NoGatewayError unless gateway
178
+ gateway.update_invoice(self)
179
+ end
180
+
181
+ def to_xml(b = Builder::XmlMarkup.new)
182
+ b.Invoice {
183
+ b.InvoiceID self.invoice_id if self.invoice_id
184
+ b.InvoiceNumber self.invoice_number if invoice_number
185
+ b.Type self.invoice_type
186
+ b.CurrencyCode self.currency_code if self.currency_code
187
+ contact.to_xml(b)
188
+ b.Date Invoice.format_date(self.date || Date.today)
189
+ b.DueDate Invoice.format_date(self.due_date) if self.due_date
190
+ b.Status self.invoice_status if self.invoice_status
191
+ b.Reference self.reference if self.reference
192
+ b.LineAmountTypes self.line_amount_types
193
+ b.LineItems {
194
+ self.line_items.each do |line_item|
195
+ line_item.to_xml(b)
196
+ end
197
+ }
198
+ b.Url url if url
199
+ }
200
+ end
201
+
202
+ #TODO UpdatedDateUTC
203
+ def self.from_xml(invoice_element, gateway = nil, options = {})
204
+ invoice = Invoice.new(options.merge({:gateway => gateway}))
205
+ invoice_element.children.each do |element|
206
+ case(element.name)
207
+ when "InvoiceID" then invoice.invoice_id = element.text
208
+ when "InvoiceNumber" then invoice.invoice_number = element.text
209
+ when "Type" then invoice.invoice_type = element.text
210
+ when "CurrencyCode" then invoice.currency_code = element.text
211
+ when "Contact" then invoice.contact = Contact.from_xml(element)
212
+ when "Date" then invoice.date = parse_date(element.text)
213
+ when "DueDate" then invoice.due_date = parse_date(element.text)
214
+ when "FullyPaidOnDate" then invoice.fully_paid_on = parse_date(element.text)
215
+ when "Status" then invoice.invoice_status = element.text
216
+ when "Reference" then invoice.reference = element.text
217
+ when "LineAmountTypes" then invoice.line_amount_types = element.text
218
+ when "LineItems" then element.children.each {|line_item| invoice.line_items_downloaded = true; invoice.line_items << LineItem.from_xml(line_item) }
219
+ when "SubTotal" then invoice.sub_total = BigDecimal.new(element.text)
220
+ when "TotalTax" then invoice.total_tax = BigDecimal.new(element.text)
221
+ when "Total" then invoice.total = BigDecimal.new(element.text)
222
+ when "InvoiceID" then invoice.invoice_id = element.text
223
+ when "InvoiceNumber" then invoice.invoice_number = element.text
224
+ when "Payments" then element.children.each { | payment | invoice.payments << Payment.from_xml(payment) }
225
+ when "AmountDue" then invoice.amount_due = BigDecimal.new(element.text)
226
+ when "AmountPaid" then invoice.amount_paid = BigDecimal.new(element.text)
227
+ when "AmountCredited" then invoice.amount_credited = BigDecimal.new(element.text)
228
+ when "SentToContact" then invoice.sent_to_contact = (element.text.strip.downcase == "true")
229
+ when "Url" then invoice.url = element.text
230
+ when "UpdatedDateUTC" then invoice.updated_date_utc = parse_utc_date_time(element.text)
231
+ end
232
+ end
233
+ invoice
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,125 @@
1
+ require File.join(File.dirname(__FILE__), 'account')
2
+
3
+ module XeroGateway
4
+ class LineItem
5
+ include Money
6
+
7
+ TAX_TYPE = Account::TAX_TYPE unless defined?(TAX_TYPE)
8
+
9
+ # Any errors that occurred when the #valid? method called.
10
+ attr_reader :errors
11
+
12
+ # All accessible fields
13
+ attr_accessor :line_item_id, :description, :quantity, :unit_amount, :item_code, :tax_type, :tax_amount, :account_code, :tracking
14
+
15
+ def initialize(params = {})
16
+ @errors ||= []
17
+ @tracking ||= []
18
+ @quantity = 1
19
+ @unit_amount = BigDecimal.new('0')
20
+
21
+ params.each do |k,v|
22
+ self.send("#{k}=", v)
23
+ end
24
+ end
25
+
26
+ # Validate the LineItem record according to what will be valid by the gateway.
27
+ #
28
+ # Usage:
29
+ # line_item.valid? # Returns true/false
30
+ #
31
+ # Additionally sets line_item.errors array to an array of field/error.
32
+ def valid?
33
+ @errors = []
34
+
35
+ if !line_item_id.nil? && line_item_id !~ GUID_REGEX
36
+ @errors << ['line_item_id', 'must be blank or a valid Xero GUID']
37
+ end
38
+
39
+ unless description
40
+ @errors << ['description', "can't be blank"]
41
+ end
42
+
43
+ if tax_type && !TAX_TYPE[tax_type]
44
+ @errors << ['tax_type', "must be one of #{TAX_TYPE.keys.join('/')}"]
45
+ end
46
+
47
+ @errors.size == 0
48
+ end
49
+
50
+ def has_tracking?
51
+ return false if tracking.nil?
52
+
53
+ if tracking.is_a?(Array)
54
+ return tracking.any?
55
+ else
56
+ return tracking.is_a?(TrackingCategory)
57
+ end
58
+ end
59
+
60
+ # Deprecated (but API for setter remains).
61
+ #
62
+ # As line_amount must equal quantity * unit_amount for the API call to pass, this is now
63
+ # automatically calculated in the line_amount method.
64
+ def line_amount=(value)
65
+ end
66
+
67
+ # Calculate the line_amount as quantity * unit_amount as this value must be correct
68
+ # for the API call to succeed.
69
+ def line_amount
70
+ quantity * unit_amount
71
+ end
72
+
73
+ def to_xml(b = Builder::XmlMarkup.new)
74
+ b.LineItem {
75
+ b.Description description
76
+ b.Quantity quantity if quantity
77
+ b.UnitAmount LineItem.format_money(unit_amount)
78
+ b.ItemCode item_code if item_code
79
+ b.TaxType tax_type if tax_type
80
+ b.TaxAmount tax_amount if tax_amount
81
+ b.LineAmount line_amount if line_amount
82
+ b.AccountCode account_code if account_code
83
+ if has_tracking?
84
+ b.Tracking {
85
+ # Due to strange retardness in the Xero API, the XML structure for a tracking category within
86
+ # an invoice is different to a standalone tracking category.
87
+ # This means rather than going category.to_xml we need to call the special category.to_xml_for_invoice_messages
88
+ (tracking.is_a?(TrackingCategory) ? [tracking] : tracking).each do |category|
89
+ category.to_xml_for_invoice_messages(b)
90
+ end
91
+ }
92
+ end
93
+ }
94
+ end
95
+
96
+ def self.from_xml(line_item_element)
97
+ line_item = LineItem.new
98
+ line_item_element.children.each do |element|
99
+ case(element.name)
100
+ when "LineItemID" then line_item.line_item_id = element.text
101
+ when "Description" then line_item.description = element.text
102
+ when "Quantity" then line_item.quantity = BigDecimal(element.text)
103
+ when "UnitAmount" then line_item.unit_amount = BigDecimal.new(element.text)
104
+ when "ItemCode" then line_item.item_code = element.text
105
+ when "TaxType" then line_item.tax_type = element.text
106
+ when "TaxAmount" then line_item.tax_amount = BigDecimal.new(element.text)
107
+ when "LineAmount" then line_item.line_amount = BigDecimal.new(element.text)
108
+ when "AccountCode" then line_item.account_code = element.text
109
+ when "Tracking" then
110
+ element.children.each do | tracking_element |
111
+ line_item.tracking << TrackingCategory.from_xml(tracking_element)
112
+ end
113
+ end
114
+ end
115
+ line_item
116
+ end
117
+
118
+ def ==(other)
119
+ [:description, :quantity, :unit_amount, :tax_type, :tax_amount, :line_amount, :account_code, :item_code].each do |field|
120
+ return false if send(field) != other.send(field)
121
+ end
122
+ return true
123
+ end
124
+ end
125
+ end