quickbooks-ruby 0.0.1

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.
@@ -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