xero_gateway 2.1.0 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +3 -11
  3. data/README.md +212 -0
  4. data/Rakefile +0 -1
  5. data/lib/oauth/oauth_consumer.rb +25 -9
  6. data/lib/xero_gateway.rb +5 -1
  7. data/lib/xero_gateway/address.rb +29 -27
  8. data/lib/xero_gateway/bank_transaction.rb +11 -3
  9. data/lib/xero_gateway/base_record.rb +97 -0
  10. data/lib/xero_gateway/contact_group.rb +87 -0
  11. data/lib/xero_gateway/currency.rb +8 -54
  12. data/lib/xero_gateway/dates.rb +4 -0
  13. data/lib/xero_gateway/exceptions.rb +14 -13
  14. data/lib/xero_gateway/gateway.rb +90 -5
  15. data/lib/xero_gateway/http.rb +16 -8
  16. data/lib/xero_gateway/invoice.rb +9 -3
  17. data/lib/xero_gateway/item.rb +27 -0
  18. data/lib/xero_gateway/line_item_calculations.rb +3 -3
  19. data/lib/xero_gateway/manual_journal.rb +5 -2
  20. data/lib/xero_gateway/organisation.rb +22 -73
  21. data/lib/xero_gateway/payment.rb +22 -9
  22. data/lib/xero_gateway/phone.rb +2 -0
  23. data/lib/xero_gateway/report.rb +95 -0
  24. data/lib/xero_gateway/response.rb +12 -7
  25. data/lib/xero_gateway/tax_rate.rb +13 -61
  26. data/lib/xero_gateway/tracking_category.rb +39 -13
  27. data/lib/xero_gateway/version.rb +3 -0
  28. data/test/integration/get_items_test.rb +25 -0
  29. data/test/integration/get_payments_test.rb +54 -0
  30. data/test/test_helper.rb +51 -16
  31. data/test/unit/address_test.rb +34 -0
  32. data/test/unit/bank_transaction_test.rb +7 -0
  33. data/test/unit/contact_group_test.rb +47 -0
  34. data/test/unit/contact_test.rb +35 -16
  35. data/test/unit/credit_note_test.rb +2 -2
  36. data/test/unit/gateway_test.rb +170 -1
  37. data/test/unit/invoice_test.rb +27 -7
  38. data/test/unit/item_test.rb +51 -0
  39. data/test/unit/organisation_test.rb +1 -0
  40. data/test/unit/payment_test.rb +18 -11
  41. data/test/unit/report_test.rb +78 -0
  42. data/xero_gateway.gemspec +29 -13
  43. metadata +176 -89
  44. data/README.textile +0 -357
  45. data/init.rb +0 -1
@@ -79,15 +79,20 @@ module XeroGateway
79
79
  handle_oauth_error!(response)
80
80
  when 404
81
81
  handle_object_not_found!(response, url)
82
+ when 500..504
83
+ # Xero sends certain oauth errors back as 500 errors
84
+ handle_oauth_error!(response)
82
85
  else
83
86
  raise "Unknown response code: #{response.code.to_i}"
84
87
  end
85
88
  end
86
89
 
87
90
  def handle_oauth_error!(response)
88
- error_details = CGI.parse(response.plain_body)
91
+ error_details = CGI.parse(response.plain_body.strip)
89
92
  description = error_details["oauth_problem_advice"].first
90
93
 
94
+ description = "No description found: #{response.plain_body}" if description.blank?
95
+
91
96
  # see http://oauth.pbworks.com/ProblemReporting
92
97
  # In addition to token_expired and token_rejected, Xero also returns
93
98
  # 'rate limit exceeded' when more than 60 requests have been made in
@@ -97,7 +102,9 @@ module XeroGateway
97
102
  when "consumer_key_unknown" then raise OAuth::TokenInvalid.new(description)
98
103
  when "token_rejected" then raise OAuth::TokenInvalid.new(description)
99
104
  when "rate limit exceeded" then raise OAuth::RateLimitExceeded.new(description)
100
- else raise OAuth::UnknownError.new(error_details["oauth_problem"].first + ':' + description)
105
+ else
106
+ message = (error_details["oauth_problem"].first || "Unknown Error") + ': ' + description
107
+ raise OAuth::UnknownError.new(message)
101
108
  end
102
109
  end
103
110
 
@@ -111,26 +118,27 @@ module XeroGateway
111
118
 
112
119
  doc = REXML::Document.new(raw_response, :ignore_whitespace_nodes => :all)
113
120
 
114
- if doc.root.name == "ApiException"
121
+ if doc.root.nil? || doc.root.name != "ApiException"
122
+
123
+ raise "Unparseable 400 Response: #{raw_response}"
124
+
125
+ else
115
126
 
116
127
  raise ApiException.new(doc.root.elements["Type"].text,
117
128
  doc.root.elements["Message"].text,
118
129
  request_xml,
119
130
  raw_response)
120
131
 
121
- else
122
-
123
- raise "Unparseable 400 Response: #{raw_response}"
124
-
125
132
  end
126
-
127
133
  end
128
134
 
129
135
  def handle_object_not_found!(response, request_url)
130
136
  case(request_url)
131
137
  when /Invoices/ then raise InvoiceNotFoundError.new("Invoice not found in Xero.")
132
138
  when /BankTransactions/ then raise BankTransactionNotFoundError.new("Bank Transaction not found in Xero.")
139
+ when /Payments/ then raise PaymentNotFoundError.new("Payments not found in Xero.")
133
140
  when /CreditNotes/ then raise CreditNoteNotFoundError.new("Credit Note not found in Xero.")
141
+ when /ManualJournals/ then raise ManualJournalNotFoundError.new("Manual Journal not found in Xero.")
134
142
  else raise ObjectNotFound.new(request_url)
135
143
  end
136
144
  end
@@ -37,9 +37,8 @@ module XeroGateway
37
37
  attr_accessor :line_items_downloaded
38
38
 
39
39
  # All accessible fields
40
- attr_accessor :invoice_id, :invoice_number, :invoice_type, :invoice_status, :date, :due_date, :reference, :branding_theme_id, :line_amount_types, :currency_code, :line_items, :contact, :payments, :fully_paid_on, :amount_due, :amount_paid, :amount_credited, :sent_to_contact, :url
40
+ attr_accessor :invoice_id, :invoice_number, :invoice_type, :invoice_status, :date, :due_date, :reference, :branding_theme_id, :line_amount_types, :currency_code, :line_items, :contact, :payments, :fully_paid_on, :amount_due, :amount_paid, :amount_credited, :sent_to_contact, :url, :updated_date_utc
41
41
 
42
-
43
42
  def initialize(params = {})
44
43
  @errors ||= []
45
44
  @payments ||= []
@@ -123,7 +122,13 @@ module XeroGateway
123
122
  def line_items_downloaded?
124
123
  @line_items_downloaded
125
124
  end
126
-
125
+
126
+ %w(sub_total tax_total total).each do |line_item_total_type|
127
+ define_method("#{line_item_total_type}=") do |new_total|
128
+ instance_variable_set("@#{line_item_total_type}", new_total) unless line_items_downloaded?
129
+ end
130
+ end
131
+
127
132
  # If line items are not downloaded, then attempt a download now (if this record was found to begin with).
128
133
  def line_items
129
134
  if line_items_downloaded?
@@ -215,6 +220,7 @@ module XeroGateway
215
220
  when "Contact" then invoice.contact = Contact.from_xml(element)
216
221
  when "Date" then invoice.date = parse_date(element.text)
217
222
  when "DueDate" then invoice.due_date = parse_date(element.text)
223
+ when "UpdatedDateUTC" then invoice.updated_date_utc = parse_date(element.text)
218
224
  when "Status" then invoice.invoice_status = element.text
219
225
  when "Reference" then invoice.reference = element.text
220
226
  when "BrandingThemeID" then invoice.branding_theme_id = element.text
@@ -0,0 +1,27 @@
1
+ module XeroGateway
2
+ class Item < BaseRecord
3
+ attributes({
4
+ "ItemID" => :string,
5
+ "Code" => :string,
6
+ "InventoryAssetAccountCode" => :string,
7
+ "Name" => :string,
8
+ "IsSold" => :boolean,
9
+ "IsPurchased" => :boolean,
10
+ "Description" => :string,
11
+ "PurchaseDescription" => :string,
12
+ "IsTrackedAsInventory" => :boolean,
13
+ "TotalCostPool" => :float,
14
+ "QuantityOnHand" => :integer,
15
+
16
+ "SalesDetails" => {
17
+ "UnitPrice" => :float,
18
+ "AccountCode" => :string
19
+ },
20
+
21
+ "PurchaseDetails" => {
22
+ "UnitPrice" => :float,
23
+ "AccountCode" => :string
24
+ }
25
+ })
26
+ end
27
+ end
@@ -20,7 +20,7 @@ module XeroGateway
20
20
 
21
21
  # Calculate the sub_total as the SUM(line_item.line_amount).
22
22
  def sub_total
23
- line_items.inject(BigDecimal.new('0')) { | sum, line_item | sum + BigDecimal.new(line_item.line_amount.to_s) }
23
+ !line_items_downloaded? && @sub_total || line_items.inject(BigDecimal.new('0')) { | sum, line_item | sum + BigDecimal.new(line_item.line_amount.to_s) }
24
24
  end
25
25
 
26
26
  # Deprecated (but API for setter remains).
@@ -32,7 +32,7 @@ module XeroGateway
32
32
 
33
33
  # Calculate the total_tax as the SUM(line_item.tax_amount).
34
34
  def total_tax
35
- line_items.inject(BigDecimal.new('0')) { | sum, line_item | sum + BigDecimal.new(line_item.tax_amount.to_s) }
35
+ !line_items_downloaded? && @total_tax || line_items.inject(BigDecimal.new('0')) { | sum, line_item | sum + BigDecimal.new(line_item.tax_amount.to_s) }
36
36
  end
37
37
 
38
38
  # Deprecated (but API for setter remains).
@@ -44,7 +44,7 @@ module XeroGateway
44
44
 
45
45
  # Calculate the toal as sub_total + total_tax.
46
46
  def total
47
- sub_total + total_tax
47
+ !line_items_downloaded? && @total || (sub_total + total_tax)
48
48
  end
49
49
 
50
50
  end
@@ -2,6 +2,9 @@ module XeroGateway
2
2
  class ManualJournal
3
3
  include Dates
4
4
 
5
+ class Error < RuntimeError; end
6
+ class NoGatewayError < Error; end
7
+
5
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)
6
9
 
7
10
  STATUSES = {
@@ -121,7 +124,7 @@ module XeroGateway
121
124
  def to_xml(b = Builder::XmlMarkup.new)
122
125
  b.ManualJournal {
123
126
  b.ManualJournalID manual_journal_id if manual_journal_id
124
- b.Narration narration
127
+ b.Narration narration
125
128
  b.JournalLines {
126
129
  self.journal_lines.each do |journal_line|
127
130
  journal_line.to_xml(b)
@@ -160,4 +163,4 @@ module XeroGateway
160
163
  end
161
164
 
162
165
  end
163
- end
166
+ end
@@ -1,75 +1,24 @@
1
1
  module XeroGateway
2
- class Organisation
3
-
4
- unless defined? ATTRS
5
- ATTRS = {
6
- "Name" => :string, # Display name of organisation shown in Xero
7
- "LegalName" => :string, # Organisation name shown on Reports
8
- "PaysTax" => :boolean, # Boolean to describe if organisation is registered with a local tax authority i.e. true, false
9
- "Version" => :string, # See Version Types
10
- "BaseCurrency" => :string, # Default currency for organisation. See Currency types
11
- "OrganisationType" => :string, # UNDOCUMENTED parameter, only returned for "real" (i.e non-demo) companies
12
- "OrganisationStatus" => :string, # UNDOCUMENTED parameter
13
- "IsDemoCompany" => :boolean, # UNDOCUMENTED parameter
14
- "APIKey" => :string, # UNDOCUMENTED paramater, returned if organisations are linked via Xero Network
15
- "CountryCode" => :string, # UNDOCUMENTED parameter
16
- "TaxNumber" => :string,
17
- "FinancialYearEndDay" => :string,
18
- "FinancialYearEndMonth" => :string,
19
- "PeriodLockDate" => :string,
20
- "CreatedDateUTC" => :string
21
- }
22
- end
23
-
24
- attr_accessor *ATTRS.keys.map(&:underscore)
25
-
26
- def initialize(params = {})
27
- params.each do |k,v|
28
- self.send("#{k}=", v)
29
- end
30
- end
31
-
32
- def ==(other)
33
- ATTRS.keys.map(&:underscore).each do |field|
34
- return false if send(field) != other.send(field)
35
- end
36
- return true
37
- end
38
-
39
- def to_xml
40
- b = Builder::XmlMarkup.new
41
-
42
- b.Organisation do
43
- ATTRS.keys.each do |attr|
44
- eval("b.#{attr} '#{self.send(attr.underscore.to_sym)}'")
45
- end
46
- end
47
- end
48
-
49
- def self.from_xml(organisation_element)
50
- Organisation.new.tap do |org|
51
- organisation_element.children.each do |element|
52
-
53
- attribute = element.name
54
- underscored_attribute = element.name.underscore
55
-
56
- if ATTRS.keys.include?(attribute)
57
-
58
- case (ATTRS[attribute])
59
- when :boolean then org.send("#{underscored_attribute}=", (element.text == "true"))
60
- when :float then org.send("#{underscored_attribute}=", element.text.to_f)
61
- else org.send("#{underscored_attribute}=", element.text)
62
- end
63
-
64
- else
65
-
66
- warn "Ignoring unknown attribute: #{attribute}"
67
-
68
- end
69
-
70
- end
71
- end
72
- end
73
-
2
+ class Organisation < BaseRecord
3
+ attributes({
4
+ "Name" => :string, # Display name of organisation shown in Xero
5
+ "LegalName" => :string, # Organisation name shown on Reports
6
+ "PaysTax" => :boolean, # Boolean to describe if organisation is registered with a local tax authority i.e. true, false
7
+ "Version" => :string, # See Version Types
8
+ "BaseCurrency" => :string, # Default currency for organisation. See Currency types
9
+ "OrganisationType" => :string, # only returned for "real" (i.e non-demo) companies
10
+ "OrganisationStatus" => :string,
11
+ "IsDemoCompany" => :boolean,
12
+ "APIKey" => :string, # returned if organisations are linked via Xero Network
13
+ "CountryCode" => :string,
14
+ "TaxNumber" => :string,
15
+ "FinancialYearEndDay" => :string,
16
+ "FinancialYearEndMonth" => :string,
17
+ "PeriodLockDate" => :string,
18
+ "CreatedDateUTC" => :string,
19
+ "ShortCode" => :string,
20
+ "Timezone" => :string,
21
+ "LineOfBusiness" => :string
22
+ })
74
23
  end
75
- end
24
+ end
@@ -7,7 +7,8 @@ module XeroGateway
7
7
  attr_reader :errors
8
8
 
9
9
  # All accessible fields
10
- attr_accessor :invoice_id, :invoice_number, :account_id, :code, :payment_id, :date, :amount, :reference, :currency_rate
10
+ attr_accessor :invoice_id, :invoice_number, :account_id, :code, :payment_id, :payment_type, :date, :amount, :reference, :currency_rate, :updated_at, :reconciled
11
+ alias_method :reconciled?, :reconciled
11
12
 
12
13
  def initialize(params = {})
13
14
  @errors ||= []
@@ -21,13 +22,18 @@ module XeroGateway
21
22
  payment = Payment.new
22
23
  payment_element.children.each do | element |
23
24
  case element.name
24
- when 'PaymentID' then payment.payment_id = element.text
25
- when 'Date' then payment.date = parse_date_time(element.text)
26
- when 'Amount' then payment.amount = BigDecimal.new(element.text)
27
- when 'Reference' then payment.reference = element.text
28
- when 'CurrencyRate' then payment.currency_rate = BigDecimal.new(element.text)
29
- when 'Invoice' then payment.send("#{element.children.first.name.underscore}=", element.children.first.text)
30
- when 'Account' then payment.send("#{element.children.first.name.underscore}=", element.children.first.text)
25
+ when 'PaymentID' then payment.payment_id = element.text
26
+ when 'PaymentType' then payment.payment_type = element.text
27
+ when 'Date' then payment.date = parse_date_time(element.text)
28
+ when 'UpdatedDateUTC' then payment.updated_at = parse_date_time(element.text)
29
+ when 'Amount' then payment.amount = BigDecimal.new(element.text)
30
+ when 'Reference' then payment.reference = element.text
31
+ when 'CurrencyRate' then payment.currency_rate = BigDecimal.new(element.text)
32
+ when 'Invoice'
33
+ payment.invoice_id = element.elements["//InvoiceID"].text
34
+ payment.invoice_number = element.elements["//InvoiceNumber"].text
35
+ when 'IsReconciled' then payment.reconciled = (element.text == "true")
36
+ when 'Account' then payment.account_id = element.elements["//AccountID"].text
31
37
  end
32
38
  end
33
39
  payment
@@ -43,6 +49,9 @@ module XeroGateway
43
49
  def to_xml(b = Builder::XmlMarkup.new)
44
50
  b.Payment do
45
51
 
52
+ b.PaymentID self.payment_id if self.payment_id
53
+ b.PaymentType self.payment_type if self.payment_type
54
+
46
55
  if self.invoice_id || self.invoice_number
47
56
  b.Invoice do |i|
48
57
  i.InvoiceID self.invoice_id if self.invoice_id
@@ -61,10 +70,14 @@ module XeroGateway
61
70
  b.CurrencyRate self.currency_rate if self.currency_rate
62
71
  b.Reference self.reference if self.reference
63
72
 
73
+ if self.reconciled?
74
+ b.IsReconciled true
75
+ end
76
+
64
77
  b.Date self.class.format_date(self.date || Date.today)
65
78
  end
66
79
  end
67
80
 
68
81
 
69
82
  end
70
- end
83
+ end
@@ -36,6 +36,8 @@ module XeroGateway
36
36
 
37
37
  unless number
38
38
  @errors << ['number', "can't be blank"]
39
+ else
40
+ @errors << ['number', "must 50 characters or less"] if number.length > 50
39
41
  end
40
42
 
41
43
  if phone_type && !PHONE_TYPE[phone_type]
@@ -0,0 +1,95 @@
1
+ module XeroGateway
2
+ class Report
3
+ include Money
4
+ include Dates
5
+
6
+ attr_reader :errors
7
+ attr_accessor :report_id, :report_name, :report_type, :report_titles, :report_date, :updated_at,
8
+ :body, :column_names
9
+
10
+ def initialize(params={})
11
+ @errors ||= []
12
+ @report_titles ||= []
13
+ @body ||= []
14
+
15
+ params.each do |k,v|
16
+ self.send("#{k}=", v)
17
+ end
18
+ end
19
+
20
+ def self.from_xml(report_element)
21
+ report = Report.new
22
+ report_element.children.each do | element |
23
+ case element.name
24
+ when 'ReportID' then report.report_id = element.text
25
+ when 'ReportName' then report.report_name = element.text
26
+ when 'ReportType' then report.report_type = element.text
27
+ when 'ReportTitles'
28
+ each_title(element) do |title|
29
+ report.report_titles << title
30
+ end
31
+ when 'ReportDate' then report.report_date = Date.parse(element.text)
32
+ when 'UpdatedDateUTC' then report.updated_at = parse_date_time_utc(element.text)
33
+ when 'Rows'
34
+ report.column_names ||= find_body_column_names(element)
35
+ each_row_content(element) do |content_hash|
36
+ report.body << OpenStruct.new(content_hash)
37
+ end
38
+ end
39
+ end
40
+ report
41
+ end
42
+
43
+ private
44
+
45
+ def self.each_row_content(xml_element, &block)
46
+ column_names = find_body_column_names(xml_element).keys
47
+ xpath_body = REXML::XPath.first(xml_element, "//RowType[text()='Section']").parent
48
+ rows_contents = []
49
+ xpath_body.elements.each("Rows/Row") do |xpath_cells|
50
+ values = find_body_cell_values(xpath_cells)
51
+ content_hash = Hash[column_names.zip values]
52
+ rows_contents << content_hash
53
+ yield content_hash if block_given?
54
+ end
55
+ rows_contents
56
+ end
57
+
58
+ def self.each_title(xml_element, &block)
59
+ xpath_titles = REXML::XPath.first(xml_element, "//ReportTitles")
60
+ xpath_titles.elements.each("//ReportTitle") do |xpath_title|
61
+ title = xpath_title.text.strip
62
+ yield title if block_given?
63
+ end
64
+ end
65
+
66
+ def self.find_body_cell_values(xml_cells)
67
+ values = []
68
+ xml_cells.elements.each("Cells/Cell") do |xml_cell|
69
+ if value = xml_cell.children.first # finds <Value>...</Value>
70
+ values << value.text.try(:strip)
71
+ next
72
+ end
73
+ values << nil
74
+ end
75
+ values
76
+ end
77
+
78
+ # returns something like { column_1: "Amount", column_2: "Description", ... }
79
+ def self.find_body_column_names(body)
80
+ header = REXML::XPath.first(body, "//RowType[text()='Header']")
81
+ names_map = {}
82
+ column_count = 0
83
+ header.parent.elements.each("Cells/Cell") do |header_cell|
84
+ column_count += 1
85
+ column_key = "column_#{column_count}".to_sym
86
+ column_name = nil
87
+ name_value = header_cell.children.first
88
+ column_name = name_value.text.strip unless name_value.blank? # finds <Value>...</Value>
89
+ names_map[column_key] = column_name
90
+ end
91
+ names_map
92
+ end
93
+
94
+ end
95
+ end
@@ -1,17 +1,20 @@
1
1
  module XeroGateway
2
2
  class Response
3
3
  attr_accessor :response_id, :status, :errors, :provider, :date_time, :response_item, :request_params, :request_xml, :response_xml
4
-
4
+
5
5
  def array_wrapped_response_item
6
6
  Array(response_item)
7
7
  end
8
-
8
+
9
9
  alias_method :invoice, :response_item
10
10
  alias_method :credit_note, :response_item
11
11
  alias_method :bank_transaction, :response_item
12
12
  alias_method :manual_journal, :response_item
13
13
  alias_method :contact, :response_item
14
+ alias_method :contact_group, :response_item
14
15
  alias_method :organisation, :response_item
16
+ alias_method :report, :response_item
17
+ alias_method :contact_groups, :array_wrapped_response_item
15
18
  alias_method :invoices, :array_wrapped_response_item
16
19
  alias_method :credit_notes, :array_wrapped_response_item
17
20
  alias_method :bank_transactions, :array_wrapped_response_item
@@ -20,22 +23,24 @@ module XeroGateway
20
23
  alias_method :accounts, :array_wrapped_response_item
21
24
  alias_method :tracking_categories, :array_wrapped_response_item
22
25
  alias_method :tax_rates, :array_wrapped_response_item
26
+ alias_method :items, :array_wrapped_response_item
23
27
  alias_method :currencies, :array_wrapped_response_item
24
-
28
+ alias_method :payments, :array_wrapped_response_item
29
+
25
30
  def initialize(params = {})
26
31
  params.each do |k,v|
27
32
  self.send("#{k}=", v)
28
33
  end
29
-
34
+
30
35
  @errors ||= []
31
36
  @response_item ||= []
32
37
  end
33
-
34
-
38
+
39
+
35
40
  def success?
36
41
  status == "OK"
37
42
  end
38
-
43
+
39
44
  def error
40
45
  errors.blank? ? nil : errors[0]
41
46
  end