quickbooks-ruby 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,21 @@
1
+ module Quickbooks
2
+ module Model
3
+ class InvoiceItemRef < BaseModel
4
+ xml_convention :camelcase
5
+ xml_accessor :name, :from => '@name' # Attribute with name 'name'
6
+ xml_accessor :value, :from => :content
7
+
8
+ def initialize(value = nil)
9
+ self.value = value
10
+ end
11
+
12
+ def to_i
13
+ self.value.to_i
14
+ end
15
+
16
+ def to_s
17
+ self.value.to_s
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,45 @@
1
+ module Quickbooks
2
+ module Model
3
+ class InvoiceLineItem < BaseModel
4
+
5
+ #== Constants
6
+ SALES_LINE_ITEM_DETAIL = 'SalesItemLineDetail'
7
+ SUB_TOTAL_LINE_DETAIL = 'SubTotalLineDetail'
8
+ PAYMENT_LINE_DETAIL = 'PaymentLineDetail'
9
+
10
+ xml_accessor :id, :from => 'Id', :as => Integer
11
+ xml_accessor :line_num, :from => 'LineNum', :as => Integer
12
+ xml_accessor :description, :from => 'Description'
13
+ xml_accessor :amount, :from => 'Amount', :as => Float
14
+ xml_accessor :detail_type, :from => 'DetailType'
15
+
16
+ #== Various detail types
17
+ xml_accessor :sales_line_item_detail, :from => 'SalesItemLineDetail', :as => Quickbooks::Model::SalesItemLineDetail
18
+ xml_accessor :sub_total_line_detail, :from => 'SubTotalLineDetail', :as => Quickbooks::Model::SubTotalLineDetail
19
+ xml_accessor :payment_line_detail, :from => 'PaymentLineDetail', :as => Quickbooks::Model::PaymentLineDetail
20
+
21
+ def sales_item?
22
+ detail_type.to_s == SALES_LINE_ITEM_DETAIL
23
+ end
24
+
25
+ def sub_total_item?
26
+ detail_type.to_s == SUB_TOTAL_LINE_DETAIL
27
+ end
28
+
29
+ def sales_item!
30
+ self.detail_type = SALES_LINE_ITEM_DETAIL
31
+ self.sales_line_item_detail = Quickbooks::Model::SalesItemLineDetail.new
32
+
33
+ yield self.sales_line_item_detail if block_given?
34
+ end
35
+
36
+ def payment_item!
37
+ self.detail_type = PAYMENT_LINE_DETAIL
38
+ self.payment_line_detail = Quickbooks::Model::PaymentLineDetail.new
39
+
40
+ yield self.payment_line_detail if block_given?
41
+ end
42
+
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,99 @@
1
+ # Business Rules
2
+ # * The item name must be unique.
3
+ # * Service item types must have IncomeAccountRef.
4
+ # * Service item types must have ExpenseAccountRef.
5
+
6
+ module Quickbooks
7
+ module Model
8
+ class Item < BaseModel
9
+
10
+ XML_COLLECTION_NODE = "Item"
11
+ XML_NODE = "Item"
12
+ REST_RESOURCE = 'item'
13
+
14
+ INVENTORY_TYPE = 'Inventory'
15
+ NON_INVENTORY_TYPE = 'Non Inventory'
16
+ SERVICE_TYPE = 'Service'
17
+ ITEM_TYPES = [INVENTORY_TYPE, NON_INVENTORY_TYPE, SERVICE_TYPE]
18
+
19
+ xml_name 'Item'
20
+ xml_accessor :id, :from => 'Id', :as => Integer
21
+ xml_accessor :sync_token, :from => 'SyncToken', :as => Integer
22
+ xml_accessor :meta_data, :from => 'MetaData', :as => Quickbooks::Model::MetaData
23
+ xml_accessor :attachable_ref, :from => 'AttachableRef', :as => Integer
24
+ xml_accessor :name, :from => 'Name'
25
+ xml_accessor :description, :from => 'Description'
26
+ xml_accessor :active, :from => 'Active'
27
+ xml_accessor :parent_ref, :from => 'ParentRef', :as => Integer
28
+ xml_accessor :sub_item, :from => 'SubItem'
29
+ xml_accessor :unit_price, :from => 'UnitPrice', :as => Float
30
+ xml_accessor :rate_percent, :from => 'RatePercent', :as => Float
31
+ xml_accessor :type, :from => 'Type'
32
+ xml_accessor :taxable, :from => 'Taxable'
33
+ xml_accessor :asset_account_ref, :from => 'AssetAccountRef', :as => Integer
34
+ xml_accessor :income_account_ref, :from => 'IncomeAccountRef', :as => Integer
35
+ xml_accessor :purchase_desc, :from => 'PurchaseDesc'
36
+ xml_accessor :purchase_cost, :from => 'PurchaseCost', :as => Float
37
+ xml_accessor :expense_account_ref, :from => 'ExpenseAccountRef', :as => Integer
38
+ xml_accessor :quantity_on_hand, :from => 'QtyOnHand', :as => Float
39
+ xml_accessor :sales_tax_code_ref, :from => 'SalesTaxCodeRef', :as => Integer
40
+ xml_accessor :purchase_tax_code_ref, :from => 'PurchaseTaxCodeRef', :as => Integer
41
+ xml_accessor :track_quantity_on_hand, :from => 'TrackQtyOnHand'
42
+ xml_accessor :asset_account, :from => 'AssetAccount', :as => Integer
43
+ xml_accessor :level, :from => 'Level', :as => Integer
44
+ xml_accessor :sales_tax_included, :from => 'SalesTaxIncluded'
45
+ xml_accessor :purchase_tax_included, :from => 'PurchaseTaxIncluded'
46
+
47
+ validates_length_of :name, :minimum => 1
48
+ validates_inclusion_of :type, :in => ITEM_TYPES
49
+
50
+ def initialize
51
+ self.type = INVENTORY_TYPE
52
+ super
53
+ end
54
+
55
+ def active?
56
+ active.to_s == 'true'
57
+ end
58
+
59
+ def sub_item?
60
+ sub_item.to_s == 'true'
61
+ end
62
+
63
+ def taxable?
64
+ taxable.to_s == 'true'
65
+ end
66
+
67
+ def track_quantity_on_hand?
68
+ track_quantity_on_hand.to_s == 'true'
69
+ end
70
+
71
+ def sales_tax_included?
72
+ sales_tax_included.to_s == 'true'
73
+ end
74
+
75
+ def purchase_tax_included?
76
+ purchase_tax_included.to_s == 'true'
77
+ end
78
+
79
+ def valid_for_create?
80
+ valid?
81
+ errors.empty?
82
+ end
83
+
84
+ # To delete an object Intuit requires we provide Id and SyncToken fields
85
+ def valid_for_deletion?
86
+ return false if(id.nil? || sync_token.nil?)
87
+ id.to_i > 0 && !sync_token.to_s.empty? && sync_token.to_i >= 0
88
+ end
89
+
90
+ def valid_for_update?
91
+ if sync_token.nil?
92
+ errors.add(:sync_token, "Missing required attribute SyncToken for update")
93
+ end
94
+ errors.empty?
95
+ end
96
+
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,9 @@
1
+ module Quickbooks
2
+ module Model
3
+ class LinkedTransaction < BaseModel
4
+ xml_accessor :txn_id, :from => 'TxnId'
5
+ xml_accessor :txn_type, :from => 'TxnType'
6
+ xml_accessor :txn_line_id, :from => 'TxnLineId'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ require 'time'
2
+
3
+ module Quickbooks
4
+ module Model
5
+ class MetaData < BaseModel
6
+ xml_accessor :create_time, :from => 'CreateTime', :as => Time
7
+ xml_accessor :last_updated_time, :from => 'LastUpdatedTime', :as => Time
8
+
9
+ def to_xml(options = {})
10
+ xml = %Q{<MetaData>}
11
+ xml = "#{xml}<CreateTime>#{formatted_date(create_time)}</CreateTime>"
12
+ xml = "#{xml}<LastUpdatedTime>#{formatted_date(last_updated_time)}</LastUpdatedTime></MetaData>"
13
+ xml
14
+ end
15
+
16
+ private
17
+
18
+ def formatted_date(datetime)
19
+ datetime.strftime('%Y-%m-%dT%H:%M:%S%z')
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,10 @@
1
+ module Quickbooks
2
+ module Model
3
+ class PaymentLineDetail < BaseModel
4
+ xml_accessor :item_ref, :from => 'ItemRef', :as => Integer
5
+ xml_accessor :class_ref, :from => 'ClassRef', :as => Integer
6
+ xml_accessor :balance, :from => 'Balance', :as => Float
7
+ xml_accessor :discount, :from => 'Discount', :as => Quickbooks::Model::DiscountOverride
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,24 @@
1
+ module Quickbooks
2
+ module Model
3
+ class PhysicalAddress < BaseModel
4
+ xml_accessor :id, :from => 'Id', :as => Integer
5
+ xml_accessor :line1, :from => 'Line1'
6
+ xml_accessor :line2, :from => 'Line2'
7
+ xml_accessor :line3, :from => 'Line3'
8
+ xml_accessor :line4, :from => 'Line4'
9
+ xml_accessor :line5, :from => 'Line5'
10
+ xml_accessor :city, :from => 'City'
11
+ xml_accessor :country, :from => 'Country'
12
+ xml_accessor :country_sub_division_code, :from => 'CountrySubDivisionCode'
13
+ xml_accessor :postal_code, :from => 'PostalCode'
14
+ xml_accessor :note, :from => 'Note'
15
+ xml_accessor :lat, :from => 'Lat', :as => Float
16
+ xml_accessor :lon, :from => 'Long', :as => Float
17
+
18
+ def zip
19
+ postal_code
20
+ end
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ module Quickbooks
2
+ module Model
3
+ class SalesItemLineDetail < BaseModel
4
+ xml_accessor :item_ref, :from => 'ItemRef', :as => Integer
5
+ xml_accessor :class_ref, :from => 'ClassRef', :as => Integer
6
+ xml_accessor :unit_price, :from => 'UnitPrice', :as => Float
7
+ xml_accessor :rate_percent, :from => 'RatePercent', :as => Float
8
+ xml_accessor :price_level_ref, :from => 'PriceLevelRef', :as => Integer
9
+ xml_accessor :quantity, :from => 'Qty', :as => Float
10
+ xml_accessor :tax_code_ref, :from => 'TaxCodeRef'
11
+ xml_accessor :service_date, :from => 'ServiceDate', :as => Date
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ module Quickbooks
2
+ module Model
3
+ class SubTotalLineDetail < BaseModel
4
+ xml_accessor :item_ref, :from => 'ItemRef', :as => Integer
5
+ xml_accessor :class_ref, :from => 'ClassRef'
6
+ xml_accessor :unit_price, :from => 'UnitPrice', :as => Float
7
+ xml_accessor :quantity, :from => 'Qty', :as => Float
8
+ xml_accessor :tax_code_ref, :from => 'TaxCodeRef'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ module Quickbooks
2
+ module Model
3
+ class TelephoneNumber < BaseModel
4
+ xml_accessor :free_form_number, :from => 'FreeFormNumber'
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,16 @@
1
+ module Quickbooks
2
+ module Model
3
+ class WebSiteAddress < BaseModel
4
+ xml_accessor :uri, :from => 'URI'
5
+
6
+ def initialize(uri = nil)
7
+ self.uri = uri if uri
8
+ end
9
+
10
+ def to_xml(options = {})
11
+ return "" if uri.to_s.empty?
12
+ super(options)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,317 @@
1
+ #require 'rexml/document'
2
+ require 'uri'
3
+ require 'cgi'
4
+
5
+ class IntuitRequestException < StandardError
6
+ attr_accessor :message, :code, :detail, :type
7
+ def initialize(msg)
8
+ self.message = msg
9
+ super(msg)
10
+ end
11
+ end
12
+ class AuthorizationFailure < StandardError; end
13
+
14
+ module Quickbooks
15
+ module Service
16
+ class BaseService
17
+ include Quickbooks::Util::Logging
18
+
19
+ attr_accessor :company_id
20
+ attr_accessor :oauth
21
+ attr_reader :base_uri
22
+ attr_reader :last_response_body
23
+ attr_reader :last_response_xml
24
+
25
+ XML_NS = %{xmlns="http://schema.intuit.com/finance/v3"}
26
+ HTTP_CONTENT_TYPE = 'application/xml'
27
+ HTTP_ACCEPT = 'application/xml'
28
+
29
+ def initialize()
30
+ @base_uri = 'https://qb.sbfinance.intuit.com/v3/company'
31
+ end
32
+
33
+ def access_token=(token)
34
+ @oauth = token
35
+ end
36
+
37
+ def company_id=(company_id)
38
+ @company_id = company_id
39
+ end
40
+
41
+ # realm & company are synonymous
42
+ def realm_id=(company_id)
43
+ @company_id = company_id
44
+ end
45
+
46
+ def url_for_resource(resource)
47
+ "#{url_for_base}/#{resource}"
48
+ end
49
+
50
+ def url_for_base
51
+ "#{@base_uri}/#{@company_id}"
52
+ end
53
+
54
+ def url_for_query(query = "")
55
+ "#{url_for_base}/query?query=#{URI.encode(query)}"
56
+ end
57
+
58
+ private
59
+
60
+ def parse_xml(xml)
61
+ @last_response_xml =
62
+ begin
63
+ x = Nokogiri::XML(xml)
64
+ #x.document.remove_namespaces!
65
+ x
66
+ end
67
+ end
68
+
69
+ def valid_xml_document(xml)
70
+ %Q{<?xml version="1.0" encoding="utf-8"?>\n#{xml.strip}}
71
+ end
72
+
73
+ # A single object response is the same as a collection response except
74
+ # it just has a single main element
75
+ def fetch_object(model, url, params = {}, options = {})
76
+ raise ArgumentError, "missing model to instantiate" if model.nil?
77
+ response = do_http_get(url, params)
78
+ collection = parse_collection(response, model)
79
+ if collection.is_a?(Quickbooks::Collection)
80
+ collection.entries.first
81
+ else
82
+ nil
83
+ end
84
+ end
85
+
86
+ def fetch_collection(query, model, options = {})
87
+ page = options.fetch(:page, 1)
88
+ per_page = options.fetch(:per_page, 20)
89
+
90
+ if page == 1
91
+ start_position = 1
92
+ else
93
+ start_position = (page * per_page) + 1 # page=2, per_page=10 then we want to start at 11
94
+ end
95
+
96
+ max_results = (page * per_page)
97
+
98
+ query = "#{query} STARTPOSITION #{start_position} MAXRESULTS #{max_results}"
99
+ response = do_http_get(url_for_query(query))
100
+
101
+ parse_collection(response, model)
102
+ end
103
+
104
+ #
105
+ # def fetch_collection2(model, custom_field_query = nil, filters = [], page = 1, per_page = 20, sort = nil, options ={})
106
+ # raise ArgumentError, "missing model to instantiate" if model.nil?
107
+ #
108
+ # post_body_tags = []
109
+ #
110
+ # # pagination parameters must come first
111
+ # post_body_tags << "<StartPage>#{page}</StartPage>"
112
+ # post_body_tags << "<ChunkSize>#{per_page}</ChunkSize>"
113
+ #
114
+ # # ... followed by any filters
115
+ # if filters.is_a?(Array) && filters.length > 0
116
+ # filters = enforce_filter_order(filters).compact
117
+ # post_body_tags << filters.collect { |f| f.to_xml }
118
+ # post_body_tags.flatten!
119
+ # end
120
+ #
121
+ # if sort
122
+ # post_body_tags << sort.to_xml
123
+ # end
124
+ #
125
+ # post_body_tags << custom_field_query
126
+ #
127
+ # xml_query_tag = "#{model::XML_NODE}Query"
128
+ # body = %Q{<?xml version="1.0" encoding="utf-8"?>\n<#{xml_query_tag} xmlns="http://www.intuit.com/sb/cdm/v2">#{post_body_tags.join}</#{xml_query_tag}>}
129
+ #
130
+ # response = do_http_post(url_for_resource(model::REST_RESOURCE), body, {}, {'Content-Type' => 'text/xml'})
131
+ # parse_collection(response, model)
132
+ # end
133
+
134
+ def parse_collection(response, model)
135
+ if response
136
+ collection = Quickbooks::Collection.new
137
+ xml = @last_response_xml
138
+ begin
139
+ results = []
140
+
141
+ query_response = xml.xpath("//xmlns:IntuitResponse/xmlns:QueryResponse")[0]
142
+ if query_response
143
+
144
+ start_pos_attr = query_response.attributes['startPosition']
145
+ if start_pos_attr
146
+ collection.start_position = start_pos_attr.value.to_i
147
+ end
148
+
149
+ max_results_attr = query_response.attributes['maxResults']
150
+ if max_results_attr
151
+ collection.max_results = max_results_attr.value.to_i
152
+ end
153
+
154
+ total_count_attr = query_response.attributes['totalCount']
155
+ if total_count_attr
156
+ collection.total_count = total_count_attr.value.to_i
157
+ end
158
+ end
159
+
160
+ path_to_nodes = "//xmlns:IntuitResponse//xmlns:#{model::XML_NODE}"
161
+ collection.count = xml.xpath(path_to_nodes).count
162
+ if collection.count > 0
163
+ xml.xpath(path_to_nodes).each do |xa|
164
+ entry = model.from_xml(xa)
165
+ results << entry
166
+ end
167
+ end
168
+ collection.entries = results
169
+ rescue => ex
170
+ #log("Error parsing XML: #{ex.message}")
171
+ raise IntuitRequestException.new("Error parsing XML: #{ex.message}")
172
+ end
173
+ collection
174
+ else
175
+ nil
176
+ end
177
+ end
178
+
179
+ # Given an IntuitResponse which is expected to wrap a single
180
+ # Entity node, e.g.
181
+ # <IntuitResponse xmlns="http://schema.intuit.com/finance/v3" time="2013-11-16T10:26:42.762-08:00">
182
+ # <Customer domain="QBO" sparse="false">
183
+ # <Id>1</Id>
184
+ # ...
185
+ # </Customer>
186
+ # </IntuitResponse>
187
+ def parse_singular_entity_response(model, xml)
188
+ xmldoc = Nokogiri(xml)
189
+ xmldoc.xpath("//xmlns:IntuitResponse/xmlns:#{model::XML_NODE}")[0]
190
+ end
191
+
192
+ # A successful delete request returns a XML packet like:
193
+ # <IntuitResponse xmlns="http://schema.intuit.com/finance/v3" time="2013-04-23T08:30:33.626-07:00">
194
+ # <Payment domain="QBO" status="Deleted">
195
+ # <Id>8748</Id>
196
+ # </Payment>
197
+ # </IntuitResponse>
198
+ def parse_singular_entity_response_for_delete(model, xml)
199
+ xmldoc = Nokogiri(xml)
200
+ xmldoc.xpath("//xmlns:IntuitResponse/xmlns:#{model::XML_NODE}[@status='Deleted']").length == 1
201
+ end
202
+
203
+ def perform_write(model, body = "", params = {}, headers = {})
204
+ url = url_for_resource(model::REST_RESOURCE)
205
+ unless headers.has_key?('Content-Type')
206
+ headers['Content-Type'] = 'text/xml'
207
+ end
208
+ response = do_http_post(url, body.strip, params, headers)
209
+
210
+ result = nil
211
+ if response
212
+ case response.code.to_i
213
+ when 200
214
+ result = Quickbooks::Model::RestResponse.from_xml(response.body)
215
+ when 401
216
+ raise IntuitRequestException.new("Authorization failure: token timed out?")
217
+ when 404
218
+ raise IntuitRequestException.new("Resource Not Found: Check URL and try again")
219
+ end
220
+ end
221
+ result
222
+ end
223
+
224
+ def do_http_post(url, body = "", params = {}, headers = {}) # throws IntuitRequestException
225
+ url = add_query_string_to_url(url, params)
226
+ do_http(:post, url, body, headers)
227
+ end
228
+
229
+ def do_http_get(url, params = {}, headers = {}) # throws IntuitRequestException
230
+ do_http(:get, url, {}, headers)
231
+ end
232
+
233
+ def do_http(method, url, body, headers) # throws IntuitRequestException
234
+ if @oauth.nil?
235
+ raise "OAuth client has not been initialized. Initialize with setter access_token="
236
+ end
237
+ unless headers.has_key?('Content-Type')
238
+ headers.merge!({'Content-Type' => HTTP_CONTENT_TYPE})
239
+ end
240
+ # log "------ New Request ------"
241
+ # log "METHOD = #{method}"
242
+ # log "RESOURCE = #{url}"
243
+ # log "BODY(#{body.class}) = #{body == nil ? "<NIL>" : body.inspect}"
244
+ # log "HEADERS = #{headers.inspect}"
245
+ response = @oauth.request(method, url, body, headers)
246
+ check_response(response)
247
+ end
248
+
249
+ def add_query_string_to_url(url, params)
250
+ if params.is_a?(Hash) && !params.empty?
251
+ url + "?" + params.collect { |k| "#{k.first}=#{k.last}" }.join("&")
252
+ else
253
+ url
254
+ end
255
+ end
256
+
257
+ def check_response(response)
258
+ # puts "RESPONSE CODE = #{response.code}"
259
+ # puts "RESPONSE BODY = #{response.body}"
260
+ parse_xml(response.body)
261
+ status = response.code.to_i
262
+ case status
263
+ when 200
264
+ # even HTTP 200 can contain an error, so we always have to peek for an Error
265
+ if response_is_error?
266
+ parse_and_raise_exception
267
+ else
268
+ response
269
+ end
270
+ when 302
271
+ raise "Unhandled HTTP Redirect"
272
+ when 401
273
+ raise AuthorizationFailure
274
+ when 400, 500
275
+ parse_and_raise_exception
276
+ else
277
+ raise "HTTP Error Code: #{status}, Msg: #{response.body}"
278
+ end
279
+ end
280
+
281
+ def parse_and_raise_exception
282
+ err = parse_intuit_error
283
+ ex = IntuitRequestException.new(err[:message])
284
+ ex.code = err[:code]
285
+ ex.detail = err[:detail]
286
+ ex.type = err[:type]
287
+
288
+ raise ex
289
+ end
290
+
291
+ def response_is_error?
292
+ @last_response_xml.xpath("//xmlns:IntuitResponse/xmlns:Fault")[0] != nil
293
+ end
294
+
295
+ def parse_intuit_error
296
+ error = {:message => "", :detail => "", :type => nil, :code => 0}
297
+ fault = @last_response_xml.xpath("//xmlns:IntuitResponse/xmlns:Fault")[0]
298
+ if fault
299
+ error[:type] = fault.attributes['type'].value
300
+
301
+ error_element = fault.xpath("//xmlns:Error")[0]
302
+ if error_element
303
+ code_attr = error_element.attributes['code']
304
+ if code_attr
305
+ error[:code] = code_attr.value
306
+ end
307
+ error[:message] = error_element.xpath("//xmlns:Message").text
308
+ error[:detail] = error_element.xpath("//xmlns:Detail").text
309
+ end
310
+ end
311
+
312
+ error
313
+ end
314
+
315
+ end
316
+ end
317
+ end