xeroizer 0.2.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -465,4 +465,23 @@ Reports are accessed like the following example:
465
465
 
466
466
  end
467
467
  end
468
-
468
+
469
+ Xero API Rate Limits
470
+ --------------------
471
+
472
+ The Xero API imposes the following limits on calls per organisation:
473
+
474
+ * A limit of 60 API calls in any 60 second period
475
+ * A limit of 1000 API calls in any 24 hour period
476
+
477
+ By default, the library will raise a `Xeroizer::OAuth::RateLimitExceeded`
478
+ exception when one of these limits is exceeded.
479
+
480
+ If required, the library can handle these exceptions internally by sleeping
481
+ for a configurable number of seconds and then repeating the last request.
482
+ You can set this option when initializing an application:
483
+
484
+ # Sleep for 2 seconds every time the rate limit is exceeded.
485
+ client = Xeroizer::PublicApplication.new(YOUR_OAUTH_CONSUMER_KEY,
486
+ YOUR_OAUTH_CONSUMER_SECRET,
487
+ :rate_limit_sleep => 2)
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.2.1
data/lib/xeroizer.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  require 'rubygems'
2
2
  require 'date'
3
3
  require 'forwardable'
4
- require "active_support/inflector"
4
+ require 'active_support/inflector'
5
+ require 'active_support/memoizable'
5
6
  # require "active_support/core_ext"
6
7
  require 'oauth'
7
8
  require 'oauth/signature/rsa/sha1'
@@ -29,6 +30,7 @@ require 'xeroizer/models/account'
29
30
  require 'xeroizer/models/address'
30
31
  require 'xeroizer/models/branding_theme'
31
32
  require 'xeroizer/models/contact'
33
+ require 'xeroizer/models/contact_group'
32
34
  require 'xeroizer/models/credit_note'
33
35
  require 'xeroizer/models/currency'
34
36
  require 'xeroizer/models/invoice'
@@ -1,10 +1,13 @@
1
1
  module Xeroizer
2
2
  class ApiException < StandardError
3
3
 
4
- def initialize(type, message, xml)
5
- @type = type
6
- @message = message
7
- @xml = xml
4
+ attr_reader :type, :message, :xml, :request_body
5
+
6
+ def initialize(type, message, xml, request_body)
7
+ @type = type
8
+ @message = message
9
+ @xml = xml
10
+ @request_body = request_body
8
11
  end
9
12
 
10
13
  def message
@@ -6,7 +6,7 @@ module Xeroizer
6
6
  include Http
7
7
  extend Record::ApplicationHelper
8
8
 
9
- attr_reader :client, :xero_url, :logger
9
+ attr_reader :client, :xero_url, :logger, :rate_limit_sleep, :rate_limit_max_attempts
10
10
 
11
11
  extend Forwardable
12
12
  def_delegators :client, :access_token
@@ -43,8 +43,10 @@ module Xeroizer
43
43
  # @see PartnerApplication
44
44
  def initialize(consumer_key, consumer_secret, options = {})
45
45
  @xero_url = options[:xero_url] || "https://api.xero.com/api.xro/2.0"
46
+ @rate_limit_sleep = options[:rate_limit_sleep] || false
47
+ @rate_limit_max_attempts = options[:rate_limit_max_attempts] || 5
46
48
  @client = OAuth.new(consumer_key, consumer_secret, options)
47
49
  end
48
50
 
49
51
  end
50
- end
52
+ end
data/lib/xeroizer/http.rb CHANGED
@@ -64,34 +64,48 @@ module Xeroizer
64
64
  end
65
65
 
66
66
  uri = URI.parse(url)
67
-
68
- logger.info("\n== [#{Time.now.to_s}] XeroGateway Request: #{uri.request_uri} ") if self.logger
69
-
70
- response = case method
71
- when :get then client.get(uri.request_uri, headers)
72
- when :post then client.post(uri.request_uri, { :xml => body }, headers)
73
- when :put then client.put(uri.request_uri, { :xml => body }, headers)
74
- end
75
-
76
- if self.logger
77
- logger.info("== [#{Time.now.to_s}] XeroGateway Response (#{response.code})")
78
-
79
- unless response.code.to_i == 200
80
- logger.info("== #{uri.request_uri} Response Body \n\n #{response.plain_body} \n == End Response Body")
67
+
68
+ attempts = 0
69
+
70
+ begin
71
+ attempts += 1
72
+ logger.info("\n== [#{Time.now.to_s}] XeroGateway Request: #{uri.request_uri} ") if self.logger
73
+
74
+ response = case method
75
+ when :get then client.get(uri.request_uri, headers)
76
+ when :post then client.post(uri.request_uri, { :xml => body }, headers)
77
+ when :put then client.put(uri.request_uri, { :xml => body }, headers)
81
78
  end
82
- end
83
-
84
- case response.code.to_i
85
- when 200
86
- response.plain_body
87
- when 400
88
- handle_error!(response)
89
- when 401
90
- handle_oauth_error!(response)
91
- when 404
92
- handle_object_not_found!(response, url)
79
+
80
+ if self.logger
81
+ logger.info("== [#{Time.now.to_s}] XeroGateway Response (#{response.code})")
82
+
83
+ unless response.code.to_i == 200
84
+ logger.info("== #{uri.request_uri} Response Body \n\n #{response.plain_body} \n == End Response Body")
85
+ end
86
+ end
87
+
88
+ case response.code.to_i
89
+ when 200
90
+ response.plain_body
91
+ when 400
92
+ handle_error!(response, body)
93
+ when 401
94
+ handle_oauth_error!(response)
95
+ when 404
96
+ handle_object_not_found!(response, url)
97
+ else
98
+ raise "Unknown response code: #{response.code.to_i}"
99
+ end
100
+ rescue Xeroizer::OAuth::RateLimitExceeded
101
+ if self.rate_limit_sleep
102
+ raise if attempts > rate_limit_max_attempts
103
+ logger.info("== Rate limit exceeded, retrying") if self.logger
104
+ sleep_for(self.rate_limit_sleep)
105
+ retry
93
106
  else
94
- raise "Unknown response code: #{response.code.to_i}"
107
+ raise
108
+ end
95
109
  end
96
110
  end
97
111
 
@@ -111,7 +125,7 @@ module Xeroizer
111
125
  end
112
126
  end
113
127
 
114
- def handle_error!(response)
128
+ def handle_error!(response, request_body)
115
129
 
116
130
  raw_response = response.plain_body
117
131
 
@@ -126,7 +140,8 @@ module Xeroizer
126
140
 
127
141
  raise ApiException.new(doc.root.xpath("Type").text,
128
142
  doc.root.xpath("Message").text,
129
- raw_response)
143
+ raw_response,
144
+ request_body)
130
145
 
131
146
  else
132
147
 
@@ -143,6 +158,10 @@ module Xeroizer
143
158
  else raise ObjectNotFound.new(request_url)
144
159
  end
145
160
  end
161
+
162
+ def sleep_for(seconds = 1)
163
+ sleep seconds
164
+ end
146
165
 
147
166
  end
148
167
  end
@@ -35,10 +35,12 @@ module Xeroizer
35
35
  'RRINPUT' => 'Reduced rate VAT on expenses (UK Only)',
36
36
  'EXEMPTOUTPUT' => 'VAT on sales exempt from VAT (UK only)',
37
37
  'OUTPUT' => 'OUTPUT',
38
+ 'OUTPUT2' => 'OUTPUT2',
38
39
  'SROUTPUT' => 'SROUTPUT',
39
40
  'ZERORATEDOUTPUT' => 'Sales made from overseas (UK only)',
40
41
  'RROUTPUT' => 'Reduced rate VAT on sales (UK Only)',
41
- 'ZERORATED' => 'Zero-rated supplies/sales from overseas (NZ Only)'
42
+ 'ZERORATED' => 'Zero-rated supplies/sales from overseas (NZ Only)',
43
+ 'ECZROUTPUT' => 'Zero-rated EC Income (UK only)'
42
44
  } unless defined?(TAX_TYPE)
43
45
 
44
46
  set_primary_key :account_id
@@ -36,6 +36,7 @@ module Xeroizer
36
36
 
37
37
  has_many :addresses
38
38
  has_many :phones
39
+ has_many :contact_groups
39
40
 
40
41
  validates_presence_of :name
41
42
  validates_inclusion_of :contact_status, :in => CONTACT_STATUS.keys, :allow_blanks => true
@@ -43,4 +44,4 @@ module Xeroizer
43
44
  end
44
45
 
45
46
  end
46
- end
47
+ end
@@ -0,0 +1,16 @@
1
+ module Xeroizer
2
+ module Record
3
+
4
+ class ContactGroupModel < BaseModel
5
+ end
6
+
7
+ class ContactGroup < Base
8
+
9
+ guid :contact_group_id
10
+ string :name
11
+ string :status
12
+
13
+ end
14
+
15
+ end
16
+ end
@@ -61,7 +61,7 @@ module Xeroizer
61
61
  #
62
62
  # @return [OAuth::Consumer] consumer object for GET/POST/PUT methods.
63
63
  def consumer
64
- @consumer ||= create_consumer
64
+ create_consumer
65
65
  end
66
66
 
67
67
  # RequestToken for PUBLIC/PARTNER authorisation
@@ -69,7 +69,7 @@ module Xeroizer
69
69
  #
70
70
  # @option params [String] :oauth_callback URL to redirect user to when they have authenticated your application with Xero. If not specified, the user will be shown an authorisation code on the screen that they need to get into your application.
71
71
  def request_token(params = {})
72
- @request_token ||= consumer.get_request_token(params)
72
+ consumer.get_request_token(params)
73
73
  end
74
74
 
75
75
  # Create an AccessToken from a PUBLIC/PARTNER authorisation.
@@ -81,7 +81,7 @@ module Xeroizer
81
81
 
82
82
  # AccessToken created from authorize_from_access method.
83
83
  def access_token
84
- @access_token ||= ::OAuth::AccessToken.new(consumer, @atoken, @asecret)
84
+ ::OAuth::AccessToken.new(consumer, @atoken, @asecret)
85
85
  end
86
86
 
87
87
  # Used for PRIVATE applications where the AccessToken uses the
@@ -54,7 +54,7 @@ module Xeroizer
54
54
  end
55
55
 
56
56
  def new_record?
57
- self.class.possible_primary_keys.all? { | key | @attributes[key].nil? }
57
+ id.nil?
58
58
  end
59
59
 
60
60
  # Check to see if the complete record is downloaded.
@@ -83,6 +83,13 @@ module Xeroizer
83
83
  end
84
84
  true
85
85
  end
86
+
87
+ def inspect
88
+ attribute_string = self.attributes.collect do |attr, value|
89
+ "#{attr.inspect}: #{value.inspect}"
90
+ end.join(", ")
91
+ "#<#{self.class} #{attribute_string}>"
92
+ end
86
93
 
87
94
  protected
88
95
 
@@ -113,4 +120,4 @@ module Xeroizer
113
120
  end
114
121
 
115
122
  end
116
- end
123
+ end
@@ -4,6 +4,8 @@ module Xeroizer
4
4
  module Record
5
5
 
6
6
  class BaseModel
7
+
8
+ extend ActiveSupport::Memoizable
7
9
 
8
10
  include ClassLevelInheritableAttributes
9
11
  class_inheritable_attributes :api_controller_name
@@ -85,7 +85,7 @@ module Xeroizer
85
85
 
86
86
  when :date
87
87
  real_value = case value
88
- when Date then
88
+ when Date then value.strftime("%Y-%m-%d")
89
89
  when Time then value.utc.strftime("%Y-%m-%d")
90
90
  end
91
91
  b.tag!(field[:api_name], real_value)
@@ -0,0 +1,44 @@
1
+ module Xeroizer
2
+ module Report
3
+ class AgedReceivablesByContact < Base
4
+
5
+ extend ActiveSupport::Memoizable
6
+
7
+ public
8
+
9
+ def total
10
+ summary.cell(:Total).value
11
+ end
12
+
13
+ def total_paid
14
+ summary.cell(:Paid).value
15
+ end
16
+
17
+ def total_credited
18
+ summary.cell(:Credited).value
19
+ end
20
+
21
+ def total_due
22
+ summary.cell(:Due).value
23
+ end
24
+
25
+ def total_overdue
26
+ now = Time.now
27
+ sum(:Due) do | row |
28
+ due_date = row.cell('Due Date').value
29
+ due_date && due_date < now
30
+ end
31
+ end
32
+
33
+ def sum(column_name, &block)
34
+ sections.first.rows.inject(BigDecimal.new('0')) do | sum, row |
35
+ cell = row.cell(column_name)
36
+ sum += row.cell(column_name).value if row.class == Xeroizer::Report::Row && (block.nil? || block.call(row))
37
+ sum
38
+ end
39
+ end
40
+ memoize :total, :total_paid, :total_credited, :total_due, :total_overdue, :sum
41
+
42
+ end
43
+ end
44
+ end
@@ -40,4 +40,4 @@ module Xeroizer
40
40
 
41
41
  end
42
42
  end
43
- end
43
+ end
@@ -32,7 +32,7 @@ module Xeroizer
32
32
  cell = new
33
33
  node.elements.each do | element |
34
34
  case element.name.to_s
35
- when 'Value' then cell.value = potentially_convert_to_number(element.text)
35
+ when 'Value' then cell.value = parse_value(element.text)
36
36
  when 'Attributes'
37
37
  element.elements.each do | attribute_node |
38
38
  (id, value) = parse_attribute(attribute_node)
@@ -44,12 +44,15 @@ module Xeroizer
44
44
  end
45
45
 
46
46
  protected
47
-
48
- # If a cell's value is a valid number then return it is as BigDecimal.
49
- def potentially_convert_to_number(value)
50
- value =~ /^[-]?\d+(\.\d+)?$/ ? BigDecimal.new(value) : value
47
+
48
+ def parse_value(value)
49
+ case value
50
+ when /^[-]?\d+(\.\d+)?$/ then BigDecimal.new(value)
51
+ when /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ then Time.xmlschema(value)
52
+ else value
53
+ end
51
54
  end
52
-
55
+
53
56
  def parse_attribute(attribute_node)
54
57
  id = nil
55
58
  value = nil
@@ -68,4 +71,4 @@ module Xeroizer
68
71
 
69
72
  end
70
73
  end
71
- end
74
+ end
@@ -1,10 +1,12 @@
1
1
  require 'xeroizer/application_http_proxy'
2
2
  require 'xeroizer/report/base'
3
+ require 'xeroizer/report/aged_receivables_by_contact'
3
4
 
4
5
  module Xeroizer
5
6
  module Report
6
7
  class Factory
7
8
 
9
+ extend ActiveSupport::Memoizable
8
10
  include ApplicationHttpProxy
9
11
 
10
12
  attr_reader :application
@@ -27,17 +29,26 @@ module Xeroizer
27
29
  end
28
30
 
29
31
  def api_controller_name
30
- "Report/#{report_type}"
32
+ "Reports/#{report_type}"
31
33
  end
32
34
 
35
+ def klass
36
+ begin
37
+ Xeroizer::Report.const_get(report_type)
38
+ rescue NameError => ex # use default class
39
+ Base
40
+ end
41
+ end
42
+ memoize :klass
43
+
33
44
  protected
34
45
 
35
46
  def parse_reports(response, elements)
36
47
  elements.each do | element |
37
- response.response_items << Base.build_from_node(element, self)
48
+ response.response_items << klass.build_from_node(element, self)
38
49
  end
39
50
  end
40
51
 
41
52
  end
42
53
  end
43
- end
54
+ end
@@ -1,7 +1,12 @@
1
1
  module Xeroizer
2
2
  module Report
3
3
  class HeaderRow < Row
4
-
4
+
5
+ def column_index(column_name)
6
+ cells.find_index { | cell | cell.value == column_name.to_s }
7
+ end
8
+ memoize :column_index
9
+
5
10
  end
6
11
  end
7
- end
12
+ end
@@ -4,6 +4,7 @@ module Xeroizer
4
4
  module Report
5
5
  class Row
6
6
 
7
+ extend ActiveSupport::Memoizable
7
8
  include RowXmlHelper
8
9
 
9
10
  attr_reader :report
@@ -37,7 +38,12 @@ module Xeroizer
37
38
  def parent?
38
39
  rows.size > 0
39
40
  end
40
-
41
+
42
+ def cell(column_name)
43
+ index = header.column_index(column_name)
44
+ cells[index] if index >= 0
45
+ end
46
+
41
47
  end
42
48
  end
43
- end
49
+ end
data/test/test_helper.rb CHANGED
@@ -51,7 +51,7 @@ module TestHelper
51
51
  end
52
52
 
53
53
  def mock_report_api(report_type)
54
- @client.stubs(:http_get).with { | client, url, params | url =~ /Report\/#{report_type}$/ }.returns(get_report_xml(report_type))
54
+ @client.stubs(:http_get).with { | client, url, params | url =~ /Reports\/#{report_type}$/ }.returns(get_report_xml(report_type))
55
55
  end
56
56
 
57
- end
57
+ end
@@ -33,13 +33,38 @@ class OAuthTest < Test::Unit::TestCase
33
33
  end
34
34
  end
35
35
 
36
- should "handle rate limit exceeded" do
36
+ should "raise rate limit exceeded" do
37
37
  Xeroizer::OAuth.any_instance.stubs(:get).returns(stub(:plain_body => get_file_as_string("rate_limit_exceeded"), :code => "401"))
38
38
 
39
39
  assert_raises Xeroizer::OAuth::RateLimitExceeded do
40
40
  @client.Organisation.first
41
41
  end
42
42
  end
43
+
44
+ should "automatically handle rate limit exceeded" do
45
+ auto_rate_limit_client = Xeroizer::PublicApplication.new(CONSUMER_KEY, CONSUMER_SECRET, :rate_limit_sleep => 1)
46
+
47
+ # Return rate limit exceeded on first call, OK on the second
48
+ Xeroizer::OAuth.any_instance.stubs(:get).returns(
49
+ stub(:plain_body => get_file_as_string("rate_limit_exceeded"), :code => "401"),
50
+ stub(:plain_body => get_record_xml(:organisation), :code => '200')
51
+ )
52
+
53
+ auto_rate_limit_client.expects(:sleep_for).with(1).returns(1)
54
+
55
+ auto_rate_limit_client.Organisation.first
56
+ end
57
+
58
+ should "only retry rate limited request a configurable number of times" do
59
+ auto_rate_limit_client = Xeroizer::PublicApplication.new(CONSUMER_KEY, CONSUMER_SECRET, :rate_limit_sleep => 1, :rate_limit_max_attempts => 4)
60
+ Xeroizer::OAuth.any_instance.stubs(:get).returns(stub(:plain_body => get_file_as_string("rate_limit_exceeded"), :code => "401"))
61
+
62
+ auto_rate_limit_client.expects(:sleep_for).with(1).times(4).returns(1)
63
+
64
+ assert_raises Xeroizer::OAuth::RateLimitExceeded do
65
+ auto_rate_limit_client.Organisation.first
66
+ end
67
+ end
43
68
 
44
69
  should "handle unknown errors" do
45
70
  Xeroizer::OAuth.any_instance.stubs(:get).returns(stub(:plain_body => get_file_as_string("bogus_oauth_error"), :code => "401"))
@@ -69,4 +94,4 @@ class OAuthTest < Test::Unit::TestCase
69
94
 
70
95
  end
71
96
 
72
- end
97
+ end
@@ -41,12 +41,12 @@ class RecordBaseTest < Test::Unit::TestCase
41
41
  assert(@contact.contact_id =~ GUID_REGEX, "@contact.contact_id is not a GUID, it is '#{@contact.contact_id}'")
42
42
  end
43
43
 
44
- should "new_record? should be false if we have specifid a primary key" do
44
+ should "new_record? should be false if we have specified a primary key" do
45
45
  contact = @client.Contact.build(:contact_id => 'ABC')
46
46
  assert_equal(false, contact.new_record?)
47
47
 
48
48
  contact = @client.Contact.build(:contact_number => 'CDE')
49
- assert_equal(false, contact.new_record?)
49
+ assert_equal(true, contact.new_record?)
50
50
 
51
51
  contact = @client.Contact.build(:name => 'TEST NAME')
52
52
  assert_equal(true, contact.new_record?)
@@ -17,7 +17,7 @@ class FactoryTest < Test::Unit::TestCase
17
17
  :BudgetSummary, :ExecutiveSummary, :ProfitAndLoss, :TrialBalance
18
18
  ].each do | report_type |
19
19
  report_factory = @client.send(report_type)
20
- assert_equal("Report/#{report_type}", report_factory.api_controller_name)
20
+ assert_equal("Reports/#{report_type}", report_factory.api_controller_name)
21
21
  end
22
22
  end
23
23
 
@@ -142,4 +142,4 @@ class FactoryTest < Test::Unit::TestCase
142
142
  end
143
143
  end
144
144
 
145
- end
145
+ end
data/xeroizer.gemspec CHANGED
@@ -5,11 +5,11 @@
5
5
 
6
6
  Gem::Specification.new do |s|
7
7
  s.name = %q{xeroizer}
8
- s.version = "0.2.0"
8
+ s.version = "0.2.1"
9
9
 
10
10
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
11
  s.authors = ["Wayne Robinson"]
12
- s.date = %q{2011-03-23}
12
+ s.date = %q{2011-05-05}
13
13
  s.description = %q{Ruby library for the Xero accounting system API.}
14
14
  s.email = %q{wayne.robinson@gmail.com}
15
15
  s.extra_rdoc_files = [
@@ -39,6 +39,7 @@ Gem::Specification.new do |s|
39
39
  "lib/xeroizer/models/address.rb",
40
40
  "lib/xeroizer/models/branding_theme.rb",
41
41
  "lib/xeroizer/models/contact.rb",
42
+ "lib/xeroizer/models/contact_group.rb",
42
43
  "lib/xeroizer/models/credit_note.rb",
43
44
  "lib/xeroizer/models/currency.rb",
44
45
  "lib/xeroizer/models/invoice.rb",
@@ -72,6 +73,7 @@ Gem::Specification.new do |s|
72
73
  "lib/xeroizer/record/validators/presence_of_validator.rb",
73
74
  "lib/xeroizer/record/validators/validator.rb",
74
75
  "lib/xeroizer/record/xml_helper.rb",
76
+ "lib/xeroizer/report/aged_receivables_by_contact.rb",
75
77
  "lib/xeroizer/report/base.rb",
76
78
  "lib/xeroizer/report/cell.rb",
77
79
  "lib/xeroizer/report/cell_xml_helper.rb",
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xeroizer
3
3
  version: !ruby/object:Gem::Version
4
- hash: 23
4
+ hash: 21
5
5
  prerelease:
6
6
  segments:
7
7
  - 0
8
8
  - 2
9
- - 0
10
- version: 0.2.0
9
+ - 1
10
+ version: 0.2.1
11
11
  platform: ruby
12
12
  authors:
13
13
  - Wayne Robinson
@@ -15,7 +15,7 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2011-03-23 00:00:00 +10:00
18
+ date: 2011-05-05 00:00:00 +10:00
19
19
  default_executable:
20
20
  dependencies:
21
21
  - !ruby/object:Gem::Dependency
@@ -214,6 +214,7 @@ files:
214
214
  - lib/xeroizer/models/address.rb
215
215
  - lib/xeroizer/models/branding_theme.rb
216
216
  - lib/xeroizer/models/contact.rb
217
+ - lib/xeroizer/models/contact_group.rb
217
218
  - lib/xeroizer/models/credit_note.rb
218
219
  - lib/xeroizer/models/currency.rb
219
220
  - lib/xeroizer/models/invoice.rb
@@ -247,6 +248,7 @@ files:
247
248
  - lib/xeroizer/record/validators/presence_of_validator.rb
248
249
  - lib/xeroizer/record/validators/validator.rb
249
250
  - lib/xeroizer/record/xml_helper.rb
251
+ - lib/xeroizer/report/aged_receivables_by_contact.rb
250
252
  - lib/xeroizer/report/base.rb
251
253
  - lib/xeroizer/report/cell.rb
252
254
  - lib/xeroizer/report/cell_xml_helper.rb