tlconnor-xero_gateway 1.0.3 → 1.0.4
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.textile +11 -0
- data/README.textile +21 -8
- data/lib/xero_gateway.rb +14 -13
- data/lib/xero_gateway/account.rb +17 -2
- data/lib/xero_gateway/accounts_list.rb +77 -0
- data/lib/xero_gateway/address.rb +62 -3
- data/lib/xero_gateway/ca-certificates.crt +2560 -0
- data/lib/xero_gateway/contact.rb +110 -62
- data/lib/xero_gateway/error.rb +1 -2
- data/lib/xero_gateway/gateway.rb +55 -17
- data/lib/xero_gateway/http.rb +42 -46
- data/lib/xero_gateway/invoice.rb +132 -75
- data/lib/xero_gateway/line_item.rb +93 -7
- data/lib/xero_gateway/phone.rb +56 -2
- data/lib/xero_gateway/response.rb +1 -2
- data/lib/xero_gateway/tracking_category.rb +13 -12
- data/test/integration/accounts_list_test.rb +111 -0
- data/test/integration/create_contact_test.rb +46 -6
- data/test/integration/create_invoice_test.rb +29 -6
- data/test/test_helper.rb +4 -5
- data/test/unit/contact_test.rb +43 -0
- data/test/unit/invoice_test.rb +215 -22
- data/xero_gateway.gemspec +5 -2
- metadata +8 -3
data/lib/xero_gateway/contact.rb
CHANGED
@@ -2,12 +2,29 @@ module XeroGateway
|
|
2
2
|
class Contact
|
3
3
|
include Dates
|
4
4
|
|
5
|
-
|
5
|
+
class Error < RuntimeError; end
|
6
|
+
class NoGatewayError < Error; end
|
7
|
+
|
8
|
+
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)
|
9
|
+
|
10
|
+
CONTACT_STATUS = {
|
11
|
+
'ACTIVE' => 'Active',
|
12
|
+
'DELETED' => 'Deleted'
|
13
|
+
} unless defined?(CONTACT_STATUS)
|
14
|
+
|
15
|
+
# Xero::Gateway associated with this contact.
|
16
|
+
attr_accessor :gateway
|
6
17
|
|
18
|
+
# Any errors that occurred when the #valid? method called.
|
19
|
+
attr_reader :errors
|
20
|
+
|
21
|
+
attr_accessor :contact_id, :contact_number, :status, :name, :email, :addresses, :phones, :updated_at
|
22
|
+
|
7
23
|
def initialize(params = {})
|
24
|
+
@errors ||= []
|
25
|
+
|
8
26
|
params = {}.merge(params)
|
9
27
|
params.each do |k,v|
|
10
|
-
self.instance_variable_set("@#{k}", v) ## create and initialize an instance variable for this key/value pair
|
11
28
|
self.send("#{k}=", v)
|
12
29
|
end
|
13
30
|
|
@@ -23,6 +40,21 @@ module XeroGateway
|
|
23
40
|
self.addresses[0] ||= Address.new
|
24
41
|
end
|
25
42
|
|
43
|
+
# Helper method to add a new address object to this contact.
|
44
|
+
#
|
45
|
+
# Usage:
|
46
|
+
# contact.add_address({
|
47
|
+
# :address_type => 'STREET',
|
48
|
+
# :line_1 => '100 Queen Street',
|
49
|
+
# :city => 'Brisbane',
|
50
|
+
# :region => 'QLD',
|
51
|
+
# :post_code => '4000',
|
52
|
+
# :country => 'Australia'
|
53
|
+
# })
|
54
|
+
def add_address(address_params)
|
55
|
+
self.addresses << Address.new(address_params)
|
56
|
+
end
|
57
|
+
|
26
58
|
def phone=(phone)
|
27
59
|
self.phones = [phone]
|
28
60
|
end
|
@@ -35,45 +67,86 @@ module XeroGateway
|
|
35
67
|
end
|
36
68
|
end
|
37
69
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
70
|
+
# Helper method to add a new phone object to this contact.
|
71
|
+
#
|
72
|
+
# Usage:
|
73
|
+
# contact.add_phone({
|
74
|
+
# :phone_type => 'MOBILE',
|
75
|
+
# :number => '0400123123'
|
76
|
+
# })
|
77
|
+
def add_phone(phone_params = {})
|
78
|
+
self.phones << Phone.new(phone_params)
|
43
79
|
end
|
44
80
|
|
45
|
-
|
46
|
-
|
81
|
+
# Validate the Contact record according to what will be valid by the gateway.
|
82
|
+
#
|
83
|
+
# Usage:
|
84
|
+
# contact.valid? # Returns true/false
|
85
|
+
#
|
86
|
+
# Additionally sets contact.errors array to an array of field/error.
|
87
|
+
def valid?
|
88
|
+
@errors = []
|
89
|
+
|
90
|
+
if !contact_id.nil? && contact_id !~ GUID_REGEX
|
91
|
+
@errors << ['contact_id', 'must be blank or a valid Xero GUID']
|
92
|
+
end
|
93
|
+
|
94
|
+
if status && !CONTACT_STATUS[status]
|
95
|
+
@errors << ['status', "must be one of #{CONTACT_STATUS.keys.join('/')}"]
|
96
|
+
end
|
97
|
+
|
98
|
+
unless name
|
99
|
+
@errors << ['name', "can't be blank"]
|
100
|
+
end
|
101
|
+
|
102
|
+
# Make sure all addresses are correct.
|
103
|
+
unless addresses.all? { | address | address.valid? }
|
104
|
+
@errors << ['addresses', 'at least one address is invalid']
|
105
|
+
end
|
106
|
+
|
107
|
+
# Make sure all phone numbers are correct.
|
108
|
+
unless phones.all? { | phone | phone.valid? }
|
109
|
+
@errors << ['phones', 'at leaset one phone is invalid']
|
110
|
+
end
|
47
111
|
|
112
|
+
@errors.size == 0
|
113
|
+
end
|
114
|
+
|
115
|
+
# General purpose create/save method.
|
116
|
+
# If contact_id and contact_number are nil then create, otherwise, attempt to save.
|
117
|
+
def save
|
118
|
+
if contact_id.nil? && contact_number.nil?
|
119
|
+
create
|
120
|
+
else
|
121
|
+
update
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# Creates this contact record (using gateway.create_contact) with the associated gateway.
|
126
|
+
# If no gateway set, raise a Xero::Contact::NoGatewayError exception.
|
127
|
+
def create
|
128
|
+
raise NoGatewayError unless gateway
|
129
|
+
gateway.create_contact(self)
|
130
|
+
end
|
131
|
+
|
132
|
+
# Creates this contact record (using gateway.update_contact) with the associated gateway.
|
133
|
+
# If no gateway set, raise a Xero::Contact::NoGatewayError exception.
|
134
|
+
def update
|
135
|
+
raise NoGatewayError unless gateway
|
136
|
+
gateway.update_contact(self)
|
137
|
+
end
|
138
|
+
|
139
|
+
def to_xml(b = Builder::XmlMarkup.new)
|
48
140
|
b.Contact {
|
49
141
|
b.ContactID self.contact_id if self.contact_id
|
50
142
|
b.ContactNumber self.contact_number if self.contact_number
|
51
143
|
b.Name self.name
|
52
144
|
b.EmailAddress self.email if self.email
|
53
145
|
b.Addresses {
|
54
|
-
|
55
|
-
b.Address {
|
56
|
-
b.AddressType address.address_type
|
57
|
-
b.AddressLine1 address.line_1 if address.line_1
|
58
|
-
b.AddressLine2 address.line_2 if address.line_2
|
59
|
-
b.AddressLine3 address.line_3 if address.line_3
|
60
|
-
b.AddressLine4 address.line_4 if address.line_4
|
61
|
-
b.City address.city if address.city
|
62
|
-
b.Region address.region if address.region
|
63
|
-
b.PostalCode address.post_code if address.post_code
|
64
|
-
b.Country address.country if address.country
|
65
|
-
}
|
66
|
-
end
|
146
|
+
addresses.each { |address| address.to_xml(b) }
|
67
147
|
}
|
68
148
|
b.Phones {
|
69
|
-
|
70
|
-
b.Phone {
|
71
|
-
b.PhoneType phone.phone_type
|
72
|
-
b.PhoneNumber phone.number
|
73
|
-
b.PhoneAreaCode phone.area_code if phone.area_code
|
74
|
-
b.PhoneCountryCode phone.country_code if phone.country_code
|
75
|
-
}
|
76
|
-
end
|
149
|
+
phones.each { |phone| phone.to_xml(b) }
|
77
150
|
}
|
78
151
|
}
|
79
152
|
end
|
@@ -88,44 +161,19 @@ module XeroGateway
|
|
88
161
|
when "ContactStatus" then contact.status = element.text
|
89
162
|
when "Name" then contact.name = element.text
|
90
163
|
when "EmailAddress" then contact.email = element.text
|
91
|
-
when "Addresses" then element.children.each {|
|
92
|
-
when "Phones" then element.children.each {|
|
164
|
+
when "Addresses" then element.children.each {|address_element| contact.addresses << Address.from_xml(address_element)}
|
165
|
+
when "Phones" then element.children.each {|phone_element| contact.phones << Phone.from_xml(phone_element)}
|
93
166
|
end
|
94
167
|
end
|
95
168
|
contact
|
96
169
|
end
|
97
170
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
address = Address.new
|
102
|
-
address_element.children.each do |element|
|
103
|
-
case(element.name)
|
104
|
-
when "AddressType" then address.address_type = element.text
|
105
|
-
when "AddressLine1" then address.line_1 = element.text
|
106
|
-
when "AddressLine2" then address.line_2 = element.text
|
107
|
-
when "AddressLine3" then address.line_3 = element.text
|
108
|
-
when "AddressLine4" then address.line_4 = element.text
|
109
|
-
when "City" then address.city = element.text
|
110
|
-
when "Region" then address.region = element.text
|
111
|
-
when "PostalCode" then address.post_code = element.text
|
112
|
-
when "Country" then address.country = element.text
|
113
|
-
end
|
114
|
-
end
|
115
|
-
address
|
116
|
-
end
|
117
|
-
|
118
|
-
def self.parse_phone(phone_element)
|
119
|
-
phone = Phone.new
|
120
|
-
phone_element.children.each do |element|
|
121
|
-
case(element.name)
|
122
|
-
when "PhoneType" then phone.phone_type = element.text
|
123
|
-
when "PhoneNumber" then phone.number = element.text
|
124
|
-
when "PhoneAreaCode" then phone.area_code = element.text
|
125
|
-
when "PhoneCountryCode" then phone.country_code = element.text
|
126
|
-
end
|
171
|
+
def ==(other)
|
172
|
+
[:contact_number, :status, :name, :email, :addresses, :phones].each do |field|
|
173
|
+
return false if send(field) != other.send(field)
|
127
174
|
end
|
128
|
-
|
175
|
+
return true
|
129
176
|
end
|
177
|
+
|
130
178
|
end
|
131
179
|
end
|
data/lib/xero_gateway/error.rb
CHANGED
@@ -4,7 +4,6 @@ module XeroGateway
|
|
4
4
|
|
5
5
|
def initialize(params = {})
|
6
6
|
params.each do |k,v|
|
7
|
-
self.instance_variable_set("@#{k}", v) ## create and initialize an instance variable for this key/value pair
|
8
7
|
self.send("#{k}=", v)
|
9
8
|
end
|
10
9
|
end
|
@@ -16,4 +15,4 @@ module XeroGateway
|
|
16
15
|
return true
|
17
16
|
end
|
18
17
|
end
|
19
|
-
end
|
18
|
+
end
|
data/lib/xero_gateway/gateway.rb
CHANGED
@@ -35,6 +35,15 @@ module XeroGateway
|
|
35
35
|
get_contact(nil, contact_number)
|
36
36
|
end
|
37
37
|
|
38
|
+
# Factory method for building new Contact objects associated with this gateway.
|
39
|
+
def build_contact(contact = {})
|
40
|
+
case contact
|
41
|
+
when Contact then contact.gateway = self
|
42
|
+
when Hash then contact = Contact.new(contact.merge({:gateway => self}))
|
43
|
+
end
|
44
|
+
contact
|
45
|
+
end
|
46
|
+
|
38
47
|
#
|
39
48
|
# Creates a contact in Xero
|
40
49
|
#
|
@@ -54,10 +63,7 @@ module XeroGateway
|
|
54
63
|
#
|
55
64
|
# create_contact(contact)
|
56
65
|
def create_contact(contact)
|
57
|
-
|
58
|
-
response_xml = http_put("#{@xero_url}/contact", request_xml, {})
|
59
|
-
|
60
|
-
parse_response(response_xml, :request_xml => request_xml)
|
66
|
+
save_contact(contact)
|
61
67
|
end
|
62
68
|
|
63
69
|
#
|
@@ -71,13 +77,9 @@ module XeroGateway
|
|
71
77
|
# xero_gateway.update_contact(contact)
|
72
78
|
def update_contact(contact)
|
73
79
|
raise "contact_id or contact_number is required for updating contacts" if contact.contact_id.nil? and contact.contact_number.nil?
|
74
|
-
|
75
|
-
request_xml = contact.to_xml
|
76
|
-
response_xml = http_post("#{@xero_url}/contact", request_xml, {})
|
77
|
-
|
78
|
-
parse_response(response_xml, :request_xml => request_xml)
|
80
|
+
save_contact(contact)
|
79
81
|
end
|
80
|
-
|
82
|
+
|
81
83
|
# Retrieves an invoice from Xero based on its GUID
|
82
84
|
#
|
83
85
|
# Usage : get_invoice_by_id("8c69117a-60ae-4d31-9eb4-7f5a76bc4947")
|
@@ -103,8 +105,19 @@ module XeroGateway
|
|
103
105
|
|
104
106
|
parse_response(response_xml, :request_params => request_params)
|
105
107
|
end
|
108
|
+
|
109
|
+
# Factory method for building new Invoice objects associated with this gateway.
|
110
|
+
def build_invoice(invoice = {})
|
111
|
+
case invoice
|
112
|
+
when Invoice then invoice.gateway = self
|
113
|
+
when Hash then invoice = Invoice.new(invoice.merge(:gateway => self))
|
114
|
+
end
|
115
|
+
invoice
|
116
|
+
end
|
106
117
|
|
107
|
-
# Creates an invoice in Xero based on an invoice object
|
118
|
+
# Creates an invoice in Xero based on an invoice object.
|
119
|
+
#
|
120
|
+
# Invoice and line item totals are calculated automatically.
|
108
121
|
#
|
109
122
|
# Usage :
|
110
123
|
#
|
@@ -113,10 +126,7 @@ module XeroGateway
|
|
113
126
|
# :due_date => 1.month.from_now,
|
114
127
|
# :invoice_number => "YOUR INVOICE NUMBER",
|
115
128
|
# :reference => "YOUR REFERENCE (NOT NECESSARILY UNIQUE!)",
|
116
|
-
# :includes_tax => false
|
117
|
-
# :sub_total => 1000,
|
118
|
-
# :total_tax => 125,
|
119
|
-
# :total => 1125
|
129
|
+
# :includes_tax => false
|
120
130
|
# })
|
121
131
|
# invoice.contact = XeroGateway::Contact.new(:name => "THE NAME OF THE CONTACT")
|
122
132
|
# invoice.contact.phone.number = "12345"
|
@@ -125,7 +135,6 @@ module XeroGateway
|
|
125
135
|
# :description => "THE DESCRIPTION OF THE LINE ITEM",
|
126
136
|
# :unit_amount => 100,
|
127
137
|
# :tax_amount => 12.5,
|
128
|
-
# :line_amount => 125,
|
129
138
|
# :tracking_category => "THE TRACKING CATEGORY FOR THE LINE ITEM",
|
130
139
|
# :tracking_option => "THE TRACKING OPTION FOR THE LINE ITEM"
|
131
140
|
# )
|
@@ -135,7 +144,10 @@ module XeroGateway
|
|
135
144
|
request_xml = invoice.to_xml
|
136
145
|
response_xml = http_put("#{@xero_url}/invoice", request_xml)
|
137
146
|
|
138
|
-
parse_response(response_xml, :request_xml => request_xml)
|
147
|
+
response = parse_response(response_xml, :request_xml => request_xml)
|
148
|
+
invoice.invoice_id = response.invoice.invoice_id if response.invoice && response.invoice.invoice_id
|
149
|
+
|
150
|
+
response
|
139
151
|
end
|
140
152
|
|
141
153
|
#
|
@@ -145,6 +157,14 @@ module XeroGateway
|
|
145
157
|
response_xml = http_get("#{xero_url}/accounts")
|
146
158
|
parse_response(response_xml)
|
147
159
|
end
|
160
|
+
|
161
|
+
#
|
162
|
+
# Returns a XeroGateway::AccountsList object that makes working with
|
163
|
+
# the Xero list of accounts easier and allows caching the results.
|
164
|
+
#
|
165
|
+
def get_accounts_list(load_on_init = true)
|
166
|
+
AccountsList.new(self, load_on_init)
|
167
|
+
end
|
148
168
|
|
149
169
|
#
|
150
170
|
# Gets all tracking categories for a specific organization in Xero.
|
@@ -170,6 +190,24 @@ module XeroGateway
|
|
170
190
|
|
171
191
|
parse_response(response_xml, :request_params => request_params)
|
172
192
|
end
|
193
|
+
|
194
|
+
# Create or update a contact record based on if it has a contact_id or contact_number.
|
195
|
+
def save_contact(contact)
|
196
|
+
request_xml = contact.to_xml
|
197
|
+
|
198
|
+
response_xml = nil
|
199
|
+
if contact.contact_id.nil? && contact.contact_number.nil?
|
200
|
+
# Create new contact record.
|
201
|
+
response_xml = http_put("#{@xero_url}/contact", request_xml, {})
|
202
|
+
else
|
203
|
+
# Update existing contact record.
|
204
|
+
response_xml = http_post("#{@xero_url}/contact", request_xml, {})
|
205
|
+
end
|
206
|
+
|
207
|
+
response = parse_response(response_xml, :request_xml => request_xml)
|
208
|
+
contact.contact_id = response.contacts.contact_id if response.contacts && response.contacts.contact_id
|
209
|
+
response
|
210
|
+
end
|
173
211
|
|
174
212
|
def parse_response(response_xml, request = {})
|
175
213
|
doc = REXML::Document.new(response_xml)
|
data/lib/xero_gateway/http.rb
CHANGED
@@ -2,58 +2,54 @@ module XeroGateway
|
|
2
2
|
module Http
|
3
3
|
OPEN_TIMEOUT = 10 unless defined? OPEN_TIMEOUT
|
4
4
|
READ_TIMEOUT = 60 unless defined? READ_TIMEOUT
|
5
|
-
|
5
|
+
ROOT_CA_FILE = File.join(File.dirname(__FILE__), 'ca-certificates.crt') unless defined? ROOT_CA_FILE
|
6
|
+
|
6
7
|
def http_get(url, extra_params = {})
|
7
|
-
|
8
|
-
params = params.merge(extra_params).map {|key,value| "#{key}=#{CGI.escape(value.to_s)}"}.join("&")
|
9
|
-
|
10
|
-
uri = URI.parse(url + "?" + params)
|
11
|
-
|
12
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
13
|
-
http.open_timeout = OPEN_TIMEOUT
|
14
|
-
http.read_timeout = READ_TIMEOUT
|
15
|
-
http.use_ssl = true
|
16
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
17
|
-
|
18
|
-
http.get(uri.request_uri).body
|
8
|
+
http_request(:get, url, nil, extra_params)
|
19
9
|
end
|
20
10
|
|
21
11
|
def http_post(url, body, extra_params = {})
|
22
|
-
|
23
|
-
headers['Content-Type'] ||= "application/x-www-form-urlencoded"
|
24
|
-
|
25
|
-
params = {:apiKey => @api_key, :xeroKey => @customer_key}
|
26
|
-
params = params.merge(extra_params).map {|key,value| "#{key}=#{CGI.escape(value.to_s)}"}.join("&")
|
27
|
-
|
28
|
-
uri = URI.parse(url + "?" + params)
|
29
|
-
|
30
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
31
|
-
http.open_timeout = OPEN_TIMEOUT
|
32
|
-
http.read_timeout = READ_TIMEOUT
|
33
|
-
http.use_ssl = true
|
34
|
-
|
35
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
36
|
-
|
37
|
-
http.post(uri.request_uri, body, headers).body
|
12
|
+
http_request(:post, url, body, extra_params)
|
38
13
|
end
|
39
14
|
|
40
15
|
def http_put(url, body, extra_params = {})
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
16
|
+
http_request(:put, url, body, extra_params)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def http_request(method, url, body, extra_params = {})
|
22
|
+
headers = {}
|
23
|
+
|
24
|
+
if method != :get
|
25
|
+
headers['Content-Type'] ||= "application/x-www-form-urlencoded"
|
26
|
+
end
|
27
|
+
|
28
|
+
params = {:apiKey => @api_key, :xeroKey => @customer_key}
|
29
|
+
params = params.merge(extra_params).map {|key,value| "#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"}.join("&")
|
30
|
+
|
31
|
+
uri = URI.parse(url + "?" + params)
|
32
|
+
|
33
|
+
# Only setup @cached_http once on first use as loading the CA file is quite expensive computationally.
|
34
|
+
unless @cached_http && @cached_http.address == uri.host && @cached_http.port == uri.port
|
35
|
+
@cached_http = Net::HTTP.new(uri.host, uri.port)
|
36
|
+
@cached_http.open_timeout = OPEN_TIMEOUT
|
37
|
+
@cached_http.read_timeout = READ_TIMEOUT
|
38
|
+
@cached_http.use_ssl = true
|
39
|
+
|
40
|
+
# Need to validate server's certificate against root certificate authority to prevent man-in-the-middle attacks.
|
41
|
+
@cached_http.ca_file = ROOT_CA_FILE
|
42
|
+
# http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
43
|
+
@cached_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
44
|
+
@cached_http.verify_depth = 5
|
45
|
+
end
|
46
|
+
|
47
|
+
case method
|
48
|
+
when :get then @cached_http.get(uri.request_uri, headers).body
|
49
|
+
when :post then @cached_http.post(uri.request_uri, body, headers).body
|
50
|
+
when :put then @cached_http.put(uri.request_uri, body, headers).body
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
58
54
|
end
|
59
55
|
end
|