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.
- data/Gemfile +12 -0
- data/LICENSE +14 -0
- data/README.textile +357 -0
- data/Rakefile +14 -0
- data/examples/oauth.rb +25 -0
- data/examples/partner_app.rb +36 -0
- data/init.rb +1 -0
- data/lib/oauth/oauth_consumer.rb +14 -0
- data/lib/xero_gateway.rb +39 -0
- data/lib/xero_gateway/account.rb +95 -0
- data/lib/xero_gateway/accounts_list.rb +87 -0
- data/lib/xero_gateway/address.rb +96 -0
- data/lib/xero_gateway/bank_transaction.rb +178 -0
- data/lib/xero_gateway/ca-certificates.crt +2560 -0
- data/lib/xero_gateway/contact.rb +206 -0
- data/lib/xero_gateway/credit_note.rb +222 -0
- data/lib/xero_gateway/currency.rb +56 -0
- data/lib/xero_gateway/dates.rb +30 -0
- data/lib/xero_gateway/error.rb +18 -0
- data/lib/xero_gateway/exceptions.rb +46 -0
- data/lib/xero_gateway/gateway.rb +622 -0
- data/lib/xero_gateway/http.rb +138 -0
- data/lib/xero_gateway/http_encoding_helper.rb +49 -0
- data/lib/xero_gateway/invoice.rb +236 -0
- data/lib/xero_gateway/line_item.rb +125 -0
- data/lib/xero_gateway/line_item_calculations.rb +55 -0
- data/lib/xero_gateway/money.rb +16 -0
- data/lib/xero_gateway/oauth.rb +87 -0
- data/lib/xero_gateway/organisation.rb +75 -0
- data/lib/xero_gateway/partner_app.rb +30 -0
- data/lib/xero_gateway/payment.rb +40 -0
- data/lib/xero_gateway/phone.rb +77 -0
- data/lib/xero_gateway/private_app.rb +17 -0
- data/lib/xero_gateway/response.rb +41 -0
- data/lib/xero_gateway/tax_rate.rb +63 -0
- data/lib/xero_gateway/tracking_category.rb +87 -0
- data/test/integration/accounts_list_test.rb +109 -0
- data/test/integration/create_bank_transaction_test.rb +38 -0
- data/test/integration/create_contact_test.rb +66 -0
- data/test/integration/create_credit_note_test.rb +49 -0
- data/test/integration/create_invoice_test.rb +49 -0
- data/test/integration/get_accounts_test.rb +23 -0
- data/test/integration/get_bank_transaction_test.rb +51 -0
- data/test/integration/get_bank_transactions_test.rb +88 -0
- data/test/integration/get_contact_test.rb +28 -0
- data/test/integration/get_contacts_test.rb +40 -0
- data/test/integration/get_credit_note_test.rb +48 -0
- data/test/integration/get_credit_notes_test.rb +90 -0
- data/test/integration/get_currencies_test.rb +25 -0
- data/test/integration/get_invoice_test.rb +48 -0
- data/test/integration/get_invoices_test.rb +92 -0
- data/test/integration/get_organisation_test.rb +24 -0
- data/test/integration/get_tax_rates_test.rb +25 -0
- data/test/integration/get_tracking_categories_test.rb +27 -0
- data/test/integration/update_bank_transaction_test.rb +31 -0
- data/test/integration/update_contact_test.rb +31 -0
- data/test/integration/update_invoice_test.rb +31 -0
- data/test/test_helper.rb +179 -0
- data/test/unit/account_test.rb +47 -0
- data/test/unit/bank_transaction_test.rb +126 -0
- data/test/unit/contact_test.rb +97 -0
- data/test/unit/credit_note_test.rb +284 -0
- data/test/unit/currency_test.rb +31 -0
- data/test/unit/gateway_test.rb +119 -0
- data/test/unit/invoice_test.rb +326 -0
- data/test/unit/oauth_test.rb +116 -0
- data/test/unit/organisation_test.rb +38 -0
- data/test/unit/tax_rate_test.rb +38 -0
- data/test/unit/tracking_category_test.rb +52 -0
- data/xero_gateway.gemspec +15 -0
- 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
|