harvested2 5.0.3

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 (106) hide show
  1. checksums.yaml +7 -0
  2. data/.document +3 -0
  3. data/.gitignore +35 -0
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +34 -0
  6. data/.ruby-version +1 -0
  7. data/.travis.yml +12 -0
  8. data/Gemfile +20 -0
  9. data/HISTORY.md +118 -0
  10. data/MIT-LICENSE +21 -0
  11. data/README.md +66 -0
  12. data/Rakefile +24 -0
  13. data/harvested2.gemspec +30 -0
  14. data/lib/ext/array.rb +52 -0
  15. data/lib/ext/date.rb +9 -0
  16. data/lib/ext/hash.rb +17 -0
  17. data/lib/ext/time.rb +5 -0
  18. data/lib/harvest/account.rb +13 -0
  19. data/lib/harvest/api/account.rb +25 -0
  20. data/lib/harvest/api/base.rb +72 -0
  21. data/lib/harvest/api/clients.rb +10 -0
  22. data/lib/harvest/api/company.rb +12 -0
  23. data/lib/harvest/api/contacts.rb +9 -0
  24. data/lib/harvest/api/expense_categories.rb +9 -0
  25. data/lib/harvest/api/expenses.rb +26 -0
  26. data/lib/harvest/api/invoice_categories.rb +9 -0
  27. data/lib/harvest/api/invoice_messages.rb +86 -0
  28. data/lib/harvest/api/invoice_payments.rb +41 -0
  29. data/lib/harvest/api/invoices.rb +9 -0
  30. data/lib/harvest/api/projects.rb +9 -0
  31. data/lib/harvest/api/task_assignments.rb +75 -0
  32. data/lib/harvest/api/tasks.rb +9 -0
  33. data/lib/harvest/api/time_entry.rb +19 -0
  34. data/lib/harvest/api/user_assignments.rb +75 -0
  35. data/lib/harvest/api/users.rb +10 -0
  36. data/lib/harvest/base.rb +333 -0
  37. data/lib/harvest/behavior/activatable.rb +31 -0
  38. data/lib/harvest/behavior/crud.rb +80 -0
  39. data/lib/harvest/client.rb +23 -0
  40. data/lib/harvest/company.rb +8 -0
  41. data/lib/harvest/contact.rb +20 -0
  42. data/lib/harvest/credentials.rb +34 -0
  43. data/lib/harvest/errors.rb +27 -0
  44. data/lib/harvest/expense.rb +54 -0
  45. data/lib/harvest/expense_category.rb +10 -0
  46. data/lib/harvest/hardy_client.rb +80 -0
  47. data/lib/harvest/invoice.rb +75 -0
  48. data/lib/harvest/invoice_category.rb +8 -0
  49. data/lib/harvest/invoice_message.rb +8 -0
  50. data/lib/harvest/invoice_payment.rb +8 -0
  51. data/lib/harvest/line_item.rb +21 -0
  52. data/lib/harvest/model.rb +133 -0
  53. data/lib/harvest/project.rb +41 -0
  54. data/lib/harvest/receipt.rb +12 -0
  55. data/lib/harvest/task.rb +21 -0
  56. data/lib/harvest/task_assignment.rb +27 -0
  57. data/lib/harvest/time_entry.rb +57 -0
  58. data/lib/harvest/timezones.rb +130 -0
  59. data/lib/harvest/user.rb +58 -0
  60. data/lib/harvest/user_assignment.rb +27 -0
  61. data/lib/harvest/version.rb +3 -0
  62. data/lib/harvested2.rb +96 -0
  63. data/spec/factories/client.rb +14 -0
  64. data/spec/factories/contact.rb +8 -0
  65. data/spec/factories/expense.rb +10 -0
  66. data/spec/factories/expenses_category.rb +7 -0
  67. data/spec/factories/invoice.rb +25 -0
  68. data/spec/factories/invoice_category.rb +5 -0
  69. data/spec/factories/invoice_message.rb +9 -0
  70. data/spec/factories/invoice_payment.rb +7 -0
  71. data/spec/factories/line_item.rb +9 -0
  72. data/spec/factories/project.rb +15 -0
  73. data/spec/factories/task.rb +8 -0
  74. data/spec/factories/task_assignment.rb +8 -0
  75. data/spec/factories/time_entry.rb +13 -0
  76. data/spec/factories/user.rb +19 -0
  77. data/spec/factories/user_assigment.rb +7 -0
  78. data/spec/functional/clients_spec.rb +105 -0
  79. data/spec/functional/errors_spec.rb +42 -0
  80. data/spec/functional/expenses_spec.rb +97 -0
  81. data/spec/functional/invoice_messages_spec.rb +48 -0
  82. data/spec/functional/invoice_payments_spec.rb +51 -0
  83. data/spec/functional/invoice_spec.rb +138 -0
  84. data/spec/functional/project_spec.rb +76 -0
  85. data/spec/functional/tasks_spec.rb +119 -0
  86. data/spec/functional/time_entries_spec.rb +87 -0
  87. data/spec/functional/users_spec.rb +72 -0
  88. data/spec/harvest/base_spec.rb +10 -0
  89. data/spec/harvest/basic_auth_credentials_spec.rb +12 -0
  90. data/spec/harvest/expense_category_spec.rb +5 -0
  91. data/spec/harvest/expense_spec.rb +18 -0
  92. data/spec/harvest/invoice_message_spec.rb +5 -0
  93. data/spec/harvest/invoice_payment_spec.rb +5 -0
  94. data/spec/harvest/invoice_spec.rb +5 -0
  95. data/spec/harvest/oauth_credentials_spec.rb +11 -0
  96. data/spec/harvest/project_spec.rb +5 -0
  97. data/spec/harvest/task_assignment_spec.rb +5 -0
  98. data/spec/harvest/task_spec.rb +5 -0
  99. data/spec/harvest/time_entry_spec.rb +23 -0
  100. data/spec/harvest/user_assignment_spec.rb +5 -0
  101. data/spec/harvest/user_spec.rb +34 -0
  102. data/spec/spec_helper.rb +22 -0
  103. data/spec/support/factory_bot.rb +5 -0
  104. data/spec/support/harvested_helpers.rb +28 -0
  105. data/spec/support/json_examples.rb +9 -0
  106. metadata +238 -0
@@ -0,0 +1,31 @@
1
+ module Harvest
2
+ module Behavior
3
+
4
+ # Activate/Deactivate behaviors that can be brought into API collections
5
+ module Activatable
6
+ # Deactivates the item. Does nothing if the item is already deactivated
7
+ #
8
+ # @param [Harvest::BaseModel] model the model you want to deactivate
9
+ # @return [Harvest::BaseModel] the deactivated model
10
+ def deactivate(model)
11
+ if model.active?
12
+ request(:post, credentials, "#{api_model.api_path}/#{model.to_i}/toggle")
13
+ model.is_active = false
14
+ end
15
+ model
16
+ end
17
+
18
+ # Activates the item. Does nothing if the item is already activated
19
+ #
20
+ # @param [Harvest::BaseModel] model the model you want to activate
21
+ # @return [Harvest::BaseModel] the activated model
22
+ def activate(model)
23
+ if !model.active?
24
+ request(:post, credentials, "#{api_model.api_path}/#{model.to_i}/toggle")
25
+ model.is_active = true
26
+ end
27
+ model
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,80 @@
1
+ module Harvest
2
+ module Behavior
3
+ module Crud
4
+ # Retrieves all items
5
+ # @return [Array<Harvest::BaseModel>] an array of models depending on where you're calling it from (e.g. [Harvest::Client] from Harvest::Base#clients)
6
+ def all(query_options = {})
7
+ response = request(:get, credentials, api_model.api_path, query: query_options)
8
+ response_parsed = api_model.to_json(response.parsed_response)
9
+
10
+ if response_parsed['total_pages'] > 1
11
+ counter = response_parsed['page']
12
+
13
+ while counter <= response_parsed['total_pages'] do
14
+ counter += 1
15
+ query_options = query_options.merge!({ 'page' => counter })
16
+
17
+ response_page = request(:get, credentials, api_model.api_path,
18
+ query: query_options)
19
+ page_result = api_model.to_json(response_page.parsed_response)
20
+ response_parsed[api_model.json_root.pluralize.to_s]
21
+ .concat(page_result[api_model.json_root.pluralize.to_s])
22
+ end
23
+ end
24
+
25
+ api_model.parse(response_parsed)
26
+ end
27
+
28
+ # Retrieves an item by id
29
+ # @overload find(id)
30
+ # @param [Integer] the id of the item you want to retreive
31
+ # @overload find(id)
32
+ # @param [String] id the String version of the id
33
+ # @overload find(model)
34
+ # @param [Harvest::BaseModel] id you can pass a model and it will return a refreshed version
35
+ #
36
+ # @return [Harvest::BaseModel] the model depends on where you're calling it from (e.g. Harvest::Client from Harvest::Base#clients)
37
+ def find(id, query_options = {})
38
+ raise 'ID is required' unless id
39
+
40
+ response = request(:get, credentials, "#{api_model.api_path}/#{id}", query: query_options)
41
+ api_model.parse(response.parsed_response).first
42
+ end
43
+
44
+ # Creates an item
45
+ # @param [Harvest::BaseModel] model the item you want to create
46
+ # @return [Harvest::BaseModel] the created model depending on where you're calling it from (e.g. Harvest::Client from Harvest::Base#clients)
47
+ def create(model)
48
+ model = api_model.wrap(model)
49
+ response = request(:post, credentials, api_model.api_path, body: model.to_json)
50
+ model = api_model.parse(response.parsed_response).first
51
+ find(model.id)
52
+ end
53
+
54
+ # Updates an item
55
+ # @param [Harvest::BaseModel] model the model you want to update
56
+ # @return [Harvest::BaseModel] the created model depending on where you're calling it from (e.g. Harvest::Client from Harvest::Base#clients)
57
+ def update(model)
58
+ model = api_model.wrap(model)
59
+ response = request(:put, credentials, "#{api_model.api_path}/#{model.id}", body: model.to_json)
60
+ model = api_model.parse(response.parsed_response).first
61
+ find(model.id)
62
+ end
63
+
64
+ # Deletes an item
65
+ # @overload delete(model)
66
+ # @param [Harvest::BaseModel] model the item you want to delete
67
+ # @overload delete(id)
68
+ # @param [Integer] id the id of the item you want to delete
69
+ # @overload delete(id)
70
+ # @param [String] id the String version of the id of the item you want to delete
71
+ #
72
+ # @return [Integer] the id of the item deleted
73
+ def delete(model)
74
+ model = api_model.wrap(model)
75
+ response = request(:delete, credentials, "#{api_model.api_path}/#{model.id}")
76
+ model.id
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,23 @@
1
+ module Harvest
2
+ # The model that contains information about a client
3
+ #
4
+ # == Fields
5
+ # [+id+] (READONLY) the id of the client
6
+ # [+name+] (REQUIRED) the name of the client
7
+ # [+details+] the details of the client
8
+ # [+currency+] what type of currency is associated with the client
9
+ # [+currency_symbol+] what currency symbol is associated with the client
10
+ # [+active?+] true|false on whether the client is active
11
+ # [+highrise_id+] (READONLY) the highrise id associated with this client
12
+ # [+update_at+] (READONLY) the last modification timestamp
13
+ class Client < Hashie::Mash
14
+ include Harvest::Model
15
+
16
+ skip_json_root true
17
+ api_path '/clients'
18
+
19
+ def is_active=(val)
20
+ self.active = val
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,8 @@
1
+ module Harvest
2
+ class Company < Hashie::Mash
3
+ include Harvest::Model
4
+
5
+ skip_json_root true
6
+ api_path '/company'
7
+ end
8
+ end
@@ -0,0 +1,20 @@
1
+ module Harvest
2
+ # The model that contains information about a client contact
3
+ #
4
+ # == Fields
5
+ # [+id+] (READONLY) the id of the contact
6
+ # [+client_id+] (REQUIRED) the id of the client this contact is associated with
7
+ # [+first_name+] (REQUIRED) the first name of the contact
8
+ # [+last_name+] (REQUIRED) the last name of the contact
9
+ # [+email+] the email of the contact
10
+ # [+title+] the title of the contact
11
+ # [+phone_office+] the office phone number of the contact
12
+ # [+phone_moble+] the moble phone number of the contact
13
+ # [+fax+] the fax number of the contact
14
+ class Contact < Hashie::Mash
15
+ include Harvest::Model
16
+
17
+ skip_json_root true
18
+ api_path '/contacts'
19
+ end
20
+ end
@@ -0,0 +1,34 @@
1
+ module Harvest
2
+ class BasicAuthCredentials
3
+ def initialize(access_token: nil, account_id: nil)
4
+ @access_token = access_token
5
+ @account_id = account_id
6
+ end
7
+
8
+ def set_authentication(request_options)
9
+ request_options[:headers] ||= {}
10
+ request_options[:headers]['Authorization'] = "Bearer #{@access_token}"
11
+ request_options[:headers]['Harvest-Account-ID'] = @account_id
12
+ end
13
+
14
+ def host
15
+ 'https://api.harvestapp.com/v2'
16
+ end
17
+ end
18
+
19
+ class OAuthCredentials
20
+ def initialize(access_token: nil, client_id: nil)
21
+ @access_token = access_token
22
+ @client_id = client_id
23
+ end
24
+
25
+ def set_authentication(request_options)
26
+ request_options[:query] ||= {}
27
+ request_options[:query]["access_token"] = @access_token
28
+ end
29
+
30
+ def host
31
+ 'https://api.harvestapp.com/v2'
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ module Harvest
2
+ class HTTPError < StandardError
3
+ attr_reader :response
4
+ attr_reader :params
5
+ attr_reader :hint
6
+
7
+ def initialize(response, params = {}, hint = nil)
8
+ @response = response
9
+ @params = params
10
+ @hint = hint
11
+ super(response)
12
+ end
13
+
14
+ def to_s
15
+ "#{self.class.to_s}: #{response.code} #{response.body}" +
16
+ (hint ? "\n#{hint}" : "")
17
+ end
18
+ end
19
+
20
+ class RateLimited < HTTPError; end
21
+ class NotFound < HTTPError; end
22
+ class Unavailable < HTTPError; end
23
+ class InformHarvest < HTTPError; end
24
+ class BadRequest < HTTPError; end
25
+ class ServerError < HTTPError; end
26
+ class AuthenticationFailed < HTTPError ; end
27
+ end
@@ -0,0 +1,54 @@
1
+ module Harvest
2
+ class Expense < Hashie::Mash
3
+ include Harvest::Model
4
+
5
+ skip_json_root true
6
+ api_path '/expenses'
7
+
8
+ delegate_methods(billed?: :is_billed,
9
+ closed?: :is_closed)
10
+
11
+ def initialize(args = {}, _ = nil)
12
+ args = args.stringify_keys
13
+ self.receipt = args.delete('receipt') if args['receipt']
14
+ self.user = args.delete('user') if args['user']
15
+ self.project = args.delete('project') if args['project']
16
+ self.client = args.delete('client') if args['client']
17
+ self.spent_date = args.delete('spent_date') if args['spent_date']
18
+ self.user_assignment = args.delete('user_assignment') if args['user_assignment']
19
+ self.expense_category = args.delete('expense_category') if args['expense_category']
20
+ super
21
+ end
22
+
23
+ def receipt=(receipt)
24
+ self['receipt_id'] = receipt['id']
25
+ self['receipt_file_name'] = receipt['file_name']
26
+ self['receipt_file_size'] = receipt['file_size']
27
+ self['receipt_content_type'] = receipt['content_type']
28
+ end
29
+
30
+ def user=(user)
31
+ self['user_id'] = user['id']
32
+ end
33
+
34
+ def project=(project)
35
+ self['project_id'] = project['id']
36
+ end
37
+
38
+ def client=(client)
39
+ self['client_id'] = client['id']
40
+ end
41
+
42
+ def spent_date=(date)
43
+ self['spent_date'] = Date.parse(date.to_s)
44
+ end
45
+
46
+ def user_assignment=(user_assignment)
47
+ self['user_assignment_id'] = user_assignment['id']
48
+ end
49
+
50
+ def expense_category=(expense_category)
51
+ self['expense_category_id'] = expense_category['id']
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,10 @@
1
+ module Harvest
2
+ class ExpenseCategory < Hashie::Mash
3
+ include Harvest::Model
4
+ api_path '/expense_categories'
5
+
6
+ def active?
7
+ !deactivated
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,80 @@
1
+ module Harvest
2
+ class HardyClient < Delegator
3
+ def initialize(client, max_retries)
4
+ super(client)
5
+ @_sd_obj = @client = client
6
+ @max_retries = max_retries
7
+ (@client.public_methods - Object.public_instance_methods).each do |name|
8
+ instance_eval <<-END
9
+ def #{name}(*args)
10
+ wrap_collection do
11
+ @client.send('#{name}', *args)
12
+ end
13
+ end
14
+ END
15
+ end
16
+ end
17
+
18
+ def __getobj__; @_sd_obj; end
19
+ def __setobj__(obj); @_sd_obj = obj; end
20
+
21
+ def wrap_collection
22
+ collection = yield
23
+ HardyCollection.new(collection, self, @max_retries)
24
+ end
25
+
26
+ class HardyCollection < Delegator
27
+ def initialize(collection, client, max_retries)
28
+ super(collection)
29
+ @_sd_obj = @collection = collection
30
+ @client = client
31
+ @max_retries = max_retries
32
+ (@collection.public_methods - Object.public_instance_methods).each do |name|
33
+ instance_eval <<-END
34
+ def #{name}(*args)
35
+ retry_rate_limits do
36
+ @collection.send('#{name}', *args)
37
+ end
38
+ end
39
+ END
40
+ end
41
+ end
42
+
43
+ def __getobj__; @_sd_obj; end
44
+ def __setobj__(obj); @_sd_obj = obj; end
45
+
46
+ def retry_rate_limits
47
+ retries = 0
48
+
49
+ retry_func = lambda do |e|
50
+ if retries < @max_retries
51
+ retries += 1
52
+ true
53
+ else
54
+ raise e
55
+ end
56
+ end
57
+
58
+ begin
59
+ yield
60
+ rescue Harvest::RateLimited => e
61
+ seconds = if e.response.headers['retry-after']
62
+ e.response.headers['retry-after'].to_i
63
+ else
64
+ 16
65
+ end
66
+ sleep(seconds)
67
+ retry
68
+ rescue Harvest::Unavailable, Harvest::InformHarvest => e
69
+ would_retry = retry_func.call(e)
70
+ sleep(16) if @client.account.rate_limit_status.over_limit?
71
+ retry if would_retry
72
+ rescue Net::HTTPError, Net::HTTPFatalError => e
73
+ retry if retry_func.call(e)
74
+ rescue SystemCallError => e
75
+ retry if e.is_a?(Errno::ECONNRESET) && retry_func.call(e)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,75 @@
1
+ module Harvest
2
+
3
+ # == Fields
4
+ # [+due_at+] when the invoice is due
5
+ # [+due_at_human_format+] when the invoice is due in human representation (e.g., due upon receipt) overrides +due_at+
6
+ # [+client_id+] (REQUIRED) the client id of the invoice
7
+ # [+currency+] the invoice currency
8
+ # [+issued_at+] when the invoice was issued
9
+ # [+subject+] subject line for the invoice
10
+ # [+notes+] notes on the invoice
11
+ # [+number+] invoice number
12
+ # [+kind+] (REQUIRED) the type of the invoice +free_form|project|task|people|detailed+
13
+ # [+projects_.idnvoice+] comma separated project ids to gather data from
14
+ # [+import_hours+] import hours from +project|task|people|detailed+ one of +yes|no+
15
+ # [+import_expenses+] import expenses from +project|task|people|detailed+ one of +yes|no+
16
+ # [+period_start+] start of the invoice period
17
+ # [+period_end+] end of the invoice period
18
+ # [+expense_period_start+] start of the invoice expense period
19
+ # [+expense_period_end+] end of the invoice expense period
20
+ # [+created_at+] (READONLY) when the invoice was created
21
+ # [+updated_at+] (READONLY) when the invoice was updated
22
+ # [+id+] (READONLY) the id of the invoice
23
+ # [+amount+] (READONLY) the amount of the invoice
24
+ # [+due_amount+] (READONLY) the amount due on the invoice
25
+ # [+created_by_id+] who created the invoice
26
+ # [+purchase_order+] purchase order number/text
27
+ # [+client_key+] unique client key
28
+ # [+state+] (READONLY) state of the invoice
29
+ # [+tax+] applied tax percentage
30
+ # [+tax2+] applied tax 2 percentage
31
+ # [+tax_amount+] amount to tax
32
+ # [+tax_amount2+] amount to tax 2
33
+ # [+discount_amount+] discount amount to apply to invoice
34
+ # [+discount_type+] discount type
35
+ # [+recurring_invoice_id+] the id of the original invoice
36
+ # [+estimate_id+] id of the related estimate
37
+ # [+retainer_id+] id of the related retainer
38
+ class Invoice < Hashie::Mash
39
+ include Harvest::Model
40
+
41
+ skip_json_root true
42
+ api_path '/invoices'
43
+
44
+ attr_reader :line_items
45
+
46
+ def initialize(args = {}, _ = nil)
47
+ if args
48
+ args = args.stringify_keys
49
+ self.client = args.delete('client') if args['client']
50
+ self.creator = args.delete('creator') if args['creator']
51
+ self.line_items = args.delete('line_items') if args['line_items']
52
+ self.line_items = [] if self.line_items.nil?
53
+ super
54
+ end
55
+ end
56
+
57
+ def client=(client)
58
+ self['client_id'] = client['id']
59
+ end
60
+
61
+ def creator=(creator)
62
+ self['creator_id'] = creator['id']
63
+ end
64
+
65
+ def line_items=(line_items)
66
+ @line_items = line_items.map do |row|
67
+ Harvest::LineItem.new(row)
68
+ end || []
69
+ end
70
+
71
+ def invoice_as_json
72
+ { 'invoice' => { 'id' => invoice_id } }
73
+ end
74
+ end
75
+ end