xero_gateway 2.1.0 → 2.3.0

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 (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