mirror42-freshbooks.rb 3.0.25
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.
- checksums.yaml +15 -0
- data/History.txt +8 -0
- data/LICENSE +10 -0
- data/Manifest.txt +45 -0
- data/README +58 -0
- data/Rakefile +27 -0
- data/lib/freshbooks.rb +95 -0
- data/lib/freshbooks/base.rb +176 -0
- data/lib/freshbooks/category.rb +11 -0
- data/lib/freshbooks/client.rb +23 -0
- data/lib/freshbooks/connection.rb +162 -0
- data/lib/freshbooks/estimate.rb +15 -0
- data/lib/freshbooks/expense.rb +12 -0
- data/lib/freshbooks/invoice.rb +22 -0
- data/lib/freshbooks/item.rb +11 -0
- data/lib/freshbooks/line.rb +11 -0
- data/lib/freshbooks/links.rb +7 -0
- data/lib/freshbooks/list_proxy.rb +80 -0
- data/lib/freshbooks/payment.rb +13 -0
- data/lib/freshbooks/project.rb +12 -0
- data/lib/freshbooks/recurring.rb +16 -0
- data/lib/freshbooks/response.rb +25 -0
- data/lib/freshbooks/schema/definition.rb +20 -0
- data/lib/freshbooks/schema/mixin.rb +40 -0
- data/lib/freshbooks/staff.rb +13 -0
- data/lib/freshbooks/task.rb +12 -0
- data/lib/freshbooks/time_entry.rb +12 -0
- data/lib/freshbooks/xml_serializer.rb +17 -0
- data/lib/freshbooks/xml_serializer/serializers.rb +116 -0
- data/script/console +10 -0
- data/script/destroy +14 -0
- data/script/generate +14 -0
- data/test/fixtures/freshbooks_credentials.sample.yml +3 -0
- data/test/fixtures/invoice_create_response.xml +4 -0
- data/test/fixtures/invoice_get_response.xml +54 -0
- data/test/fixtures/invoice_list_response.xml +109 -0
- data/test/fixtures/success_response.xml +2 -0
- data/test/mock_connection.rb +13 -0
- data/test/schema/test_definition.rb +36 -0
- data/test/schema/test_mixin.rb +39 -0
- data/test/test_base.rb +151 -0
- data/test/test_connection.rb +145 -0
- data/test/test_helper.rb +48 -0
- data/test/test_invoice.rb +125 -0
- data/test/test_list_proxy.rb +60 -0
- data/test/test_page.rb +50 -0
- metadata +157 -0
@@ -0,0 +1,23 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class Client < FreshBooks::Base
|
3
|
+
define_schema do |s|
|
4
|
+
s.string :first_name, :last_name, :organization, :email
|
5
|
+
s.string :username, :password, :work_phone, :home_phone
|
6
|
+
s.string :mobile, :fax, :notes, :p_street1, :p_street2, :p_city
|
7
|
+
s.string :p_state, :p_country, :p_code, :s_street1, :s_street2
|
8
|
+
s.string :s_city, :s_state, :s_country, :s_code
|
9
|
+
s.string :vat_name, :vat_number
|
10
|
+
s.float :credit, :read_only => true
|
11
|
+
s.date_time :updated, :read_only => true
|
12
|
+
s.fixnum :client_id
|
13
|
+
s.object :links, :read_only => true
|
14
|
+
end
|
15
|
+
|
16
|
+
actions :list, :get, :create, :update, :delete
|
17
|
+
|
18
|
+
def invoices(options = {})
|
19
|
+
options.merge!('client_id' => self.client_id)
|
20
|
+
Invoice::list(options)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
require 'net/https'
|
2
|
+
require 'rexml/document'
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
module FreshBooks
|
6
|
+
class Connection
|
7
|
+
attr_reader :account_url, :auth_token, :utc_offset, :request_headers
|
8
|
+
|
9
|
+
@@logger = Logger.new(STDOUT)
|
10
|
+
def logger
|
11
|
+
@@logger
|
12
|
+
end
|
13
|
+
|
14
|
+
def logger=(value)
|
15
|
+
@@logger = value
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.log_level=(level)
|
19
|
+
@@logger.level = level
|
20
|
+
end
|
21
|
+
self.log_level = Logger::WARN
|
22
|
+
|
23
|
+
def initialize(account_url, auth_token, request_headers = {}, options = {})
|
24
|
+
raise InvalidAccountUrlError.new unless account_url =~ /^[0-9a-zA-Z\-_]+\.freshbooks\.com$/
|
25
|
+
|
26
|
+
@account_url = account_url
|
27
|
+
@auth_token = auth_token
|
28
|
+
@request_headers = request_headers
|
29
|
+
@utc_offset = options[:utc_offset] || -4
|
30
|
+
@start_session_count = 0
|
31
|
+
end
|
32
|
+
|
33
|
+
def call_api(method, elements = [])
|
34
|
+
request = create_request(method, elements)
|
35
|
+
result = post(request)
|
36
|
+
Response.new(result)
|
37
|
+
end
|
38
|
+
|
39
|
+
def direct_post(xml)
|
40
|
+
result = post(xml)
|
41
|
+
Response.new(result)
|
42
|
+
end
|
43
|
+
|
44
|
+
def start_session(&block)
|
45
|
+
@connection = obtain_connection if @start_session_count == 0
|
46
|
+
@start_session_count = @start_session_count + 1
|
47
|
+
|
48
|
+
begin
|
49
|
+
block.call(@connection)
|
50
|
+
ensure
|
51
|
+
@start_session_count = @start_session_count - 1
|
52
|
+
close if @start_session_count == 0
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
protected
|
57
|
+
|
58
|
+
def create_request(method, elements = [])
|
59
|
+
doc = REXML::Document.new '<?xml version="1.0" encoding="UTF-8"?>'
|
60
|
+
request = doc.add_element('request')
|
61
|
+
request.attributes['method'] = method
|
62
|
+
|
63
|
+
elements.each do |element|
|
64
|
+
if element.kind_of?(Hash)
|
65
|
+
element = element.to_a
|
66
|
+
end
|
67
|
+
key = element.first
|
68
|
+
value = element.last
|
69
|
+
|
70
|
+
if value.kind_of?(Base)
|
71
|
+
request.add_element(REXML::Document.new(value.to_xml))
|
72
|
+
else
|
73
|
+
request.add_element(REXML::Element.new(key.to_s)).text = value.to_s
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
doc.to_s
|
78
|
+
end
|
79
|
+
|
80
|
+
def obtain_connection(force = false)
|
81
|
+
return @connection if @connection && !force
|
82
|
+
|
83
|
+
@connection = Net::HTTP.new(@account_url, 443)
|
84
|
+
@connection.use_ssl = true
|
85
|
+
@connection.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
86
|
+
@connection.start
|
87
|
+
end
|
88
|
+
|
89
|
+
def reconnect
|
90
|
+
close
|
91
|
+
obtain_connection(true)
|
92
|
+
end
|
93
|
+
|
94
|
+
def close
|
95
|
+
begin
|
96
|
+
@connection.finish if @connection
|
97
|
+
rescue => e
|
98
|
+
logger.error("Error closing connection: " + e.message)
|
99
|
+
end
|
100
|
+
@connection = nil
|
101
|
+
end
|
102
|
+
|
103
|
+
def post(request_body)
|
104
|
+
result = nil
|
105
|
+
request = Net::HTTP::Post.new(FreshBooks::SERVICE_URL)
|
106
|
+
request.basic_auth @auth_token, 'X'
|
107
|
+
request.body = request_body
|
108
|
+
request.content_type = 'application/xml'
|
109
|
+
@request_headers.each_pair do |name, value|
|
110
|
+
request[name.to_s] = value
|
111
|
+
end
|
112
|
+
|
113
|
+
result = post_request(request)
|
114
|
+
|
115
|
+
if logger.debug?
|
116
|
+
logger.debug "Request:"
|
117
|
+
logger.debug request_body
|
118
|
+
logger.debug "Response:"
|
119
|
+
logger.debug result.body
|
120
|
+
end
|
121
|
+
|
122
|
+
check_for_api_error(result)
|
123
|
+
end
|
124
|
+
|
125
|
+
# For connections that take a long time, we catch EOFError's and reconnect seamlessly
|
126
|
+
def post_request(request)
|
127
|
+
response = nil
|
128
|
+
has_reconnected = false
|
129
|
+
start_session do |connection|
|
130
|
+
begin
|
131
|
+
response = connection.request(request)
|
132
|
+
rescue EOFError => e
|
133
|
+
raise e if has_reconnected
|
134
|
+
|
135
|
+
has_reconnected = true
|
136
|
+
connection = reconnect
|
137
|
+
retry
|
138
|
+
end
|
139
|
+
end
|
140
|
+
response
|
141
|
+
end
|
142
|
+
|
143
|
+
def check_for_api_error(result)
|
144
|
+
return result.body if result.kind_of?(Net::HTTPSuccess)
|
145
|
+
|
146
|
+
case result
|
147
|
+
when Net::HTTPRedirection
|
148
|
+
if result["location"] =~ /loginSearch/
|
149
|
+
raise UnknownSystemError.new("Account does not exist")
|
150
|
+
elsif result["location"] =~ /deactivated/
|
151
|
+
raise AccountDeactivatedError.new("Account is deactivated")
|
152
|
+
end
|
153
|
+
when Net::HTTPUnauthorized
|
154
|
+
raise AuthenticationError.new("Invalid API key.")
|
155
|
+
when Net::HTTPBadRequest
|
156
|
+
raise ApiAccessNotEnabledError.new("API not enabled.")
|
157
|
+
end
|
158
|
+
|
159
|
+
raise InternalError.new("Invalid HTTP code: #{result.class}")
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class Estimate < FreshBooks::Base
|
3
|
+
define_schema do |s|
|
4
|
+
s.string :estimate_id, :status, :date, :notes, :terms, :first_name
|
5
|
+
s.string :last_name, :organization, :p_street1, :p_street2, :p_city
|
6
|
+
s.string :p_state, :p_country, :p_code
|
7
|
+
s.fixnum :client_id, :po_number, :number
|
8
|
+
s.float :discount, :amount
|
9
|
+
s.array :lines
|
10
|
+
s.object :links, :read_only => true
|
11
|
+
end
|
12
|
+
|
13
|
+
actions :list, :get, :create, :update, :delete, :send_by_email
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class Expense < FreshBooks::Base
|
3
|
+
define_schema do |s|
|
4
|
+
s.fixnum :expense_id, :staff_id, :category_id, :project_id, :client_id
|
5
|
+
s.float :amount, :tax1_amount, :tax1_percent, :tax2_amount, :tax2_percent
|
6
|
+
s.date :date
|
7
|
+
s.string :notes, :vendor, :status, :tax1_name, :tax2_name
|
8
|
+
end
|
9
|
+
|
10
|
+
actions :list, :get, :create, :update, :delete
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class Invoice < FreshBooks::Base
|
3
|
+
|
4
|
+
|
5
|
+
|
6
|
+
define_schema do |s|
|
7
|
+
s.fixnum :invoice_id, :client_id, :po_number
|
8
|
+
s.fixnum :recurring_id, :read_only => true
|
9
|
+
s.float :amount, :discount
|
10
|
+
s.float :amount_outstanding, :paid, :read_only => true
|
11
|
+
s.date :date
|
12
|
+
s.date_time :updated, :read_only => true
|
13
|
+
s.array :lines
|
14
|
+
s.object :links, :read_only => true
|
15
|
+
s.string :number, :organization, :status, :notes, :terms, :first_name, :last_name, :currency_code
|
16
|
+
s.string :p_street1, :p_street2, :p_city, :p_state, :p_country, :p_code
|
17
|
+
s.string :return_uri
|
18
|
+
end
|
19
|
+
|
20
|
+
actions :list, :get, :create, :update, :delete, :send_by_email, :send_by_snail_mail
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class ListProxy
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
def initialize(list_page_proc)
|
6
|
+
@list_page_proc = list_page_proc
|
7
|
+
move_to_page(1)
|
8
|
+
end
|
9
|
+
|
10
|
+
def each(&block)
|
11
|
+
move_to_page(1)
|
12
|
+
|
13
|
+
begin
|
14
|
+
@array.each(&block)
|
15
|
+
end while @current_page.next_page? && next_page
|
16
|
+
end
|
17
|
+
|
18
|
+
def [](position)
|
19
|
+
move_to_page(@current_page.page_number(position))
|
20
|
+
@array[@current_page.position_number(position)]
|
21
|
+
end
|
22
|
+
|
23
|
+
def size
|
24
|
+
@current_page.total
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def next_page
|
30
|
+
move_to_page(@current_page.page + 1)
|
31
|
+
end
|
32
|
+
|
33
|
+
def move_to_page(page)
|
34
|
+
return true if @current_page && @current_page.page == page
|
35
|
+
@array, @current_page = @list_page_proc.call(page)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class Page
|
40
|
+
attr_reader :page, :per_page, :total
|
41
|
+
|
42
|
+
def initialize(page, per_page, total, total_in_array = total)
|
43
|
+
@page = page.to_i
|
44
|
+
@per_page = per_page.to_i
|
45
|
+
@total = total.to_i
|
46
|
+
|
47
|
+
# Detect if response has pagination
|
48
|
+
if @per_page == 0 && @total == 0 && total_in_array != 0
|
49
|
+
# No pagination so fake it
|
50
|
+
@page = 1
|
51
|
+
@per_page = @total = total_in_array
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Get the page number that this element is on given the number of elements per page
|
56
|
+
def page_number(position)
|
57
|
+
(position / per_page) + 1
|
58
|
+
end
|
59
|
+
|
60
|
+
# Get the position number of this element based relative to the current page
|
61
|
+
def position_number(position)
|
62
|
+
position - ((page - 1) * per_page)
|
63
|
+
end
|
64
|
+
|
65
|
+
def pages
|
66
|
+
return 0 if per_page == 0
|
67
|
+
|
68
|
+
pages = total / per_page
|
69
|
+
|
70
|
+
if (total % per_page) != 0
|
71
|
+
pages += 1
|
72
|
+
end
|
73
|
+
pages
|
74
|
+
end
|
75
|
+
|
76
|
+
def next_page?
|
77
|
+
page < pages
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class Payment < FreshBooks::Base
|
3
|
+
define_schema do |s|
|
4
|
+
s.fixnum :client_id, :invoice_id, :payment_id
|
5
|
+
s.float :amount
|
6
|
+
s.date :date
|
7
|
+
s.date_time :updated, :read_only => true
|
8
|
+
s.string :type, :notes
|
9
|
+
end
|
10
|
+
|
11
|
+
actions :list, :get, :create, :update, :delete
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class Project < FreshBooks::Base
|
3
|
+
define_schema do |s|
|
4
|
+
s.string :name, :bill_method, :description
|
5
|
+
s.fixnum :project_id, :client_id
|
6
|
+
s.float :rate
|
7
|
+
s.array :tasks
|
8
|
+
end
|
9
|
+
|
10
|
+
actions :list, :get, :create, :update, :delete
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class Recurring < FreshBooks::Base
|
3
|
+
define_schema do |s|
|
4
|
+
s.string :first_name, :last_name, :organization, :p_street1, :p_street2, :p_city
|
5
|
+
s.string :p_state, :p_country, :p_code, :lines, :notes, :terms, :frequency
|
6
|
+
s.date :date
|
7
|
+
s.fixnum :recurring_id, :client_id, :po_number, :occurrences
|
8
|
+
s.float :discount, :amount
|
9
|
+
s.array :lines
|
10
|
+
s.boolean :stopped, :send_email, :send_snail_mail
|
11
|
+
s.string :return_uri
|
12
|
+
end
|
13
|
+
|
14
|
+
actions :list, :get, :create, :update, :delete
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
class Response
|
3
|
+
attr_accessor :doc
|
4
|
+
|
5
|
+
def initialize(xml_raw)
|
6
|
+
@doc = REXML::Document.new(xml_raw)
|
7
|
+
end
|
8
|
+
|
9
|
+
def elements
|
10
|
+
@doc.root.elements
|
11
|
+
end
|
12
|
+
|
13
|
+
def success?
|
14
|
+
@doc.root.attributes['status'] == 'ok'
|
15
|
+
end
|
16
|
+
|
17
|
+
def fail?
|
18
|
+
!success?
|
19
|
+
end
|
20
|
+
|
21
|
+
def error_msg
|
22
|
+
return @doc.root.elements['error'].text
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module FreshBooks
|
2
|
+
module Schema
|
3
|
+
class Definition
|
4
|
+
attr_reader :members
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@members = ActiveSupport::OrderedHash.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def method_missing(method, *attributes)
|
11
|
+
options = attributes.extract_options!
|
12
|
+
options[:read_only] ||= false
|
13
|
+
|
14
|
+
attributes.each do |attribute|
|
15
|
+
@members[attribute.to_s] = options.merge({ :type => method.to_sym })
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|