cashboard 1.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.
Files changed (62) hide show
  1. data/.autotest +26 -0
  2. data/.gitignore +2 -0
  3. data/LICENSE +19 -0
  4. data/README.textile +58 -0
  5. data/Rakefile +25 -0
  6. data/cashboard.gemspec +129 -0
  7. data/examples/create_account.rb +38 -0
  8. data/examples/list_stuff_in_account.rb +15 -0
  9. data/examples/simple_workflow.rb +35 -0
  10. data/examples/time_tracking.rb +30 -0
  11. data/examples/toggle_timer.rb +0 -0
  12. data/lib/cashboard/account.rb +21 -0
  13. data/lib/cashboard/base.rb +223 -0
  14. data/lib/cashboard/behaviors/base.rb +4 -0
  15. data/lib/cashboard/behaviors/lists_line_items.rb +32 -0
  16. data/lib/cashboard/behaviors/toggleable.rb +18 -0
  17. data/lib/cashboard/client_company.rb +25 -0
  18. data/lib/cashboard/client_contact.rb +32 -0
  19. data/lib/cashboard/company_membership.rb +6 -0
  20. data/lib/cashboard/document_template.rb +10 -0
  21. data/lib/cashboard/employee.rb +29 -0
  22. data/lib/cashboard/errors.rb +48 -0
  23. data/lib/cashboard/estimate.rb +43 -0
  24. data/lib/cashboard/expense.rb +14 -0
  25. data/lib/cashboard/invoice.rb +75 -0
  26. data/lib/cashboard/invoice_line_item.rb +15 -0
  27. data/lib/cashboard/invoice_payment.rb +7 -0
  28. data/lib/cashboard/line_item.rb +27 -0
  29. data/lib/cashboard/payment.rb +22 -0
  30. data/lib/cashboard/project.rb +38 -0
  31. data/lib/cashboard/project_assignment.rb +9 -0
  32. data/lib/cashboard/time_entry.rb +45 -0
  33. data/lib/cashboard/version.rb +3 -0
  34. data/lib/cashboard.rb +75 -0
  35. data/lib/typecasted_open_struct.rb +82 -0
  36. data/test/fixtures/account.xml +50 -0
  37. data/test/fixtures/cashboard_credentials.yml +3 -0
  38. data/test/fixtures/client_companies.xml +41 -0
  39. data/test/fixtures/client_contacts.xml +53 -0
  40. data/test/fixtures/company_memberships.xml +21 -0
  41. data/test/fixtures/document_templates.xml +53 -0
  42. data/test/fixtures/employees.xml +51 -0
  43. data/test/fixtures/estimates.xml +243 -0
  44. data/test/fixtures/expenses.xml +101 -0
  45. data/test/fixtures/invoice_line_items.xml +138 -0
  46. data/test/fixtures/invoice_payments.xml +10 -0
  47. data/test/fixtures/invoices.xml +231 -0
  48. data/test/fixtures/line_items.xml +243 -0
  49. data/test/fixtures/payments.xml +93 -0
  50. data/test/fixtures/project_assignments.xml +30 -0
  51. data/test/fixtures/projects.xml +129 -0
  52. data/test/fixtures/time_entries.xml +213 -0
  53. data/test/full.rb +3 -0
  54. data/test/test_helper.rb +112 -0
  55. data/test/unit/account_test.rb +85 -0
  56. data/test/unit/document_template_test.rb +18 -0
  57. data/test/unit/estimate_test.rb +166 -0
  58. data/test/unit/expense_test.rb +16 -0
  59. data/test/unit/invoice_test.rb +185 -0
  60. data/test/unit/project_test.rb +198 -0
  61. data/test/unit/time_entry_test.rb +225 -0
  62. metadata +220 -0
@@ -0,0 +1,18 @@
1
+ # Standard interface to toggle the status of something between Active
2
+ # and Closed inside Cashboard.
3
+ module Cashboard::Behaviors::Toggleable
4
+ # Toggles status of the project between active/closed
5
+ # and sets appropriate variable.
6
+ def toggle_status
7
+ options = self.class.merge_options()
8
+ options.merge!({:body => self.to_xml})
9
+ response = self.class.put(self.links[:toggle_status], options)
10
+ begin
11
+ self.class.check_status_code(response)
12
+ rescue
13
+ return false
14
+ end
15
+ self.is_active = !self.is_active
16
+ return true
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ module Cashboard
2
+ class ClientCompany < Base
3
+ element :name
4
+ element :address
5
+ element :address2
6
+ element :city
7
+ element :state
8
+ element :zip
9
+ element :country_code
10
+ element :url
11
+ element :telephone
12
+ element :currency_type_code
13
+ element :notes
14
+ element :custom_1
15
+ element :custom_2
16
+ element :custom_3
17
+
18
+ # Returns all associated CompanyMemberships
19
+ def memberships(options={})
20
+ self.class.get_collection(
21
+ self.links[:memberships], Cashboard::CompanyMembership, options
22
+ )
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ module Cashboard
2
+ class ClientContact < Base
3
+ element :api_key
4
+ element :last_login, DateTime
5
+ element :login_count, Integer
6
+ element :address
7
+ element :address2
8
+ element :city
9
+ element :country_code
10
+ element :currency_type_code
11
+ element :custom_1
12
+ element :custom_2
13
+ element :custom_3
14
+ element :email_address
15
+ element :first_name
16
+ element :last_name
17
+ element :notes
18
+ element :password
19
+ element :state
20
+ element :telephone
21
+ element :url
22
+ element :zip
23
+
24
+ # Returns all associated CompanyMemberships
25
+ def memberships(options={})
26
+ self.class.get_collection(
27
+ self.links[:memberships], Cashboard::CompanyMembership, options
28
+ )
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,6 @@
1
+ module Cashboard
2
+ class CompanyMembership < Base
3
+ element :person_id
4
+ element :company_id
5
+ end
6
+ end
@@ -0,0 +1,10 @@
1
+ module Cashboard
2
+ class DocumentTemplate < Base
3
+ element :content
4
+ element :created_at, DateTime
5
+ element :has_been_modified, Boolean
6
+ element :is_default, Boolean
7
+ element :name
8
+ element :title
9
+ end
10
+ end
@@ -0,0 +1,29 @@
1
+ module Cashboard
2
+ class Employee < Base
3
+ STATUS_CODES = {
4
+ :employee => 0,
5
+ :administrator => 2
6
+ }
7
+
8
+ element :api_key, String
9
+ element :last_login, DateTime
10
+ element :login_count, Integer
11
+ element :address
12
+ element :address2
13
+ element :city
14
+ element :country_code
15
+ element :custom_1
16
+ element :custom_2
17
+ element :custom_3
18
+ element :email_address
19
+ element :employee_status_code, Integer
20
+ element :first_name
21
+ element :last_name
22
+ element :notes
23
+ element :password
24
+ element :state
25
+ element :telephone
26
+ element :url
27
+ element :zip
28
+ end
29
+ end
@@ -0,0 +1,48 @@
1
+ module Cashboard
2
+ class Unauthorized < StandardError; end
3
+
4
+ class HTTPError < StandardError
5
+ attr_reader :response
6
+ def initialize(response)
7
+ @response = response
8
+ super
9
+ end
10
+
11
+ def to_s
12
+
13
+ #hint = response.response.body.nil? ? nil : response.response.body
14
+ #"#{self.class.to_s} : #{response.code}#{" - #{hint}" if hint}"
15
+ response.inspect
16
+ end
17
+ end
18
+
19
+ class Forbidden < HTTPError; end
20
+ class RateLimited < HTTPError; end
21
+ class NotFound < HTTPError; end
22
+ class PaymentRequired < HTTPError; end
23
+ class Unavailable < HTTPError; end
24
+ class ServerError < HTTPError; end
25
+
26
+ class BadRequest < HTTPError
27
+ # Custom parses our "errors" return XML
28
+ def to_s
29
+ response.response.body
30
+ end
31
+
32
+ # Returns a hash of errors keyed on field name.
33
+ #
34
+ # Example
35
+ # {
36
+ # :field_name_one => "Error message",
37
+ # :field_name_two => "Error message"
38
+ # }
39
+ def errors
40
+ parsed_errors = XmlSimple.xml_in(response.response.body)
41
+ error_hash = {}
42
+ parsed_errors['error'].each do |e|
43
+ error_hash[e['field']] = e['content']
44
+ end
45
+ return error_hash
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ module Cashboard
2
+ class Estimate < Base
3
+ include Cashboard::Behaviors::Toggleable
4
+ include Cashboard::Behaviors::ListsLineItems
5
+
6
+ element :assigned_id
7
+ element :agreement_text
8
+ element :client_id
9
+ element :client_type
10
+ element :created_on, DateTime
11
+ element :deposit_amount, Float
12
+ element :discount_percentage, Float
13
+ element :document_template_id
14
+ element :has_been_sent, Boolean
15
+ element :intro_text
16
+ element :is_active, Boolean
17
+ element :name
18
+ element :requires_agreement, Boolean
19
+ element :sales_tax, Float
20
+ element :sales_tax_2, Float
21
+ element :sales_tax_2_cumulative, Boolean
22
+ # Read only attributes. Can't set these via API
23
+ element :discount_best, Float
24
+ element :discount_worst, Float
25
+ element :item_actual_best, Float
26
+ element :item_actual_worst, Float
27
+ element :item_cost_best, Float
28
+ element :item_cost_worst, Float
29
+ element :item_profit_best, Float
30
+ element :item_profit_worst, Float
31
+ element :item_taxable_best, Float
32
+ element :item_taxable_worst, Float
33
+ element :price_best, Float
34
+ element :price_worst, Float
35
+ element :tax_cost_best, Float
36
+ element :tax_cost_worst, Float
37
+ element :tax_cost_2_best, Float
38
+ element :tax_cost_2_worst, Float
39
+ element :time_best, Float
40
+ element :time_worst, Float
41
+
42
+ end
43
+ end
@@ -0,0 +1,14 @@
1
+ module Cashboard
2
+ class Expense < Base
3
+ element :amount, Float
4
+ element :category
5
+ element :created_on, DateTime
6
+ element :description
7
+ element :invoice_line_item_id # read only
8
+ element :is_billable, Boolean
9
+ element :payee_id
10
+ element :payee_type
11
+ element :person_id
12
+ element :project_id
13
+ end
14
+ end
@@ -0,0 +1,75 @@
1
+ module Cashboard
2
+ class Invoice < Base
3
+ element :assigned_id
4
+ element :balance, Float # readonly
5
+ element :client_id
6
+ element :client_type
7
+ element :created_on, DateTime
8
+ element :discount, Float # readonly
9
+ element :discount_percentage, Float
10
+ element :document_template_id
11
+ element :due_date, Date
12
+ element :early_period_in_days, Integer
13
+ element :has_been_sent, Boolean
14
+ element :include_expenses, Boolean
15
+ element :include_pdf, Boolean
16
+ element :include_time_entries, Boolean
17
+ element :invoice_date, Date
18
+ element :item_actual, Float # readonly
19
+ element :item_cost, Float # readonly
20
+ element :item_profit, Float # readonly
21
+ element :item_taxable, Float # readonly
22
+ element :late_fee, Float # readonly
23
+ element :late_percentage, Float
24
+ element :late_period_in_days, Integer
25
+ element :notes
26
+ element :payment_total, Float # readonly
27
+ element :post_reminder_in_days, Integer
28
+ element :pre_reminder_in_days, Integer
29
+ element :sales_tax, Float
30
+ element :sales_tax_2, Float
31
+ element :sales_tax_2_cumulative, Boolean
32
+ element :total, Float # readonly
33
+ element :total_quantity, Float # readonly
34
+
35
+ # Returns all associated LineItems
36
+ def line_items(options={})
37
+ self.class.get_collection(
38
+ self.links[:line_items], Cashboard::InvoiceLineItem, options
39
+ )
40
+ end
41
+
42
+ # Imports uninvoiced items (time entries, expenses, flat fee tasks)
43
+ # that belong to the same client that this invoice was created for.
44
+ #
45
+ # Either raises a Cashboard error (errors.rb) or returns a collection
46
+ # of Cashboard::InvoiceLineItem objects.
47
+ def import_uninvoiced_items(project_ids={}, start_date=nil, end_date=nil)
48
+ xml_options = get_import_xml_options(project_ids, start_date, end_date)
49
+
50
+ options = self.class.merge_options()
51
+ options.merge!({:body => xml_options})
52
+ response = self.class.put(self.links[:import_uninvoiced_items], options)
53
+
54
+ self.class.check_status_code(response)
55
+
56
+ collection = response.parsed_response[Cashboard::InvoiceLineItem.resource_name.singularize]
57
+ collection.map do |h|
58
+ Cashboard::InvoiceLineItem.new(h)
59
+ end
60
+ end
61
+
62
+ def get_import_xml_options(project_ids, start_date, end_date)
63
+ xml_options = ''
64
+ xml = Builder::XmlMarkup.new(:target => xml_options, :indent => 2)
65
+ xml.instruct!
66
+ xml.projects do
67
+ project_ids.each {|pid| xml.id pid} if project_ids
68
+ end
69
+ xml.start_date start_date
70
+ xml.end_date end_date
71
+
72
+ return xml_options
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,15 @@
1
+ module Cashboard
2
+ class InvoiceLineItem < Base
3
+ element :description
4
+ element :flat_fee, Float
5
+ element :invoice_id
6
+ element :invoice_schedule_id
7
+ element :is_taxable, Boolean
8
+ element :markup_percentage, Float
9
+ element :price_per, Float
10
+ element :quantity, Float
11
+ element :rank, Integer
12
+ element :title
13
+ element :total, Float # readonly
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module Cashboard
2
+ class InvoicePayment < Base
3
+ element :invoice_id
4
+ element :payment_id
5
+ element :amount, Float
6
+ end
7
+ end
@@ -0,0 +1,27 @@
1
+ module Cashboard
2
+ class LineItem < Base
3
+ TYPE_CODES = {
4
+ :custom => 0,
5
+ :task => 1,
6
+ :product => 2
7
+ }
8
+
9
+ element :best_time_in_minutes, Integer
10
+ element :created_on, DateTime
11
+ element :description, String
12
+ element :estimate_id, String
13
+ element :flat_fee, Float
14
+ element :is_complete, Boolean
15
+ element :is_taxable, Boolean
16
+ element :markup_percentage, Float
17
+ element :price_per, Float
18
+ element :project_id, String
19
+ element :quantity_low, Float
20
+ element :quantity_high, Float
21
+ element :rank, Integer
22
+ element :title, String
23
+ element :type_code, Integer
24
+ element :unit_label, String
25
+ element :worst_time_in_minutes, Integer
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ module Cashboard
2
+ class Payment < Base
3
+ element :amount, Float
4
+ element :assigned_id
5
+ element :client_id
6
+ element :client_type
7
+ element :created_on, Date
8
+ element :document_template_id
9
+ element :estimate_id
10
+ element :notes
11
+ element :person_id
12
+ element :transaction_id
13
+
14
+ # Returns all InvoicePayments associated with this payment
15
+ def invoice_payments(options={})
16
+ self.class.get_collection(
17
+ self.links[:invoices], Cashboard::InvoicePayment, options
18
+ )
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,38 @@
1
+ module Cashboard
2
+ class Project < Base
3
+ include Cashboard::Behaviors::Toggleable
4
+ include Cashboard::Behaviors::ListsLineItems
5
+
6
+ BILLING_CODES = {
7
+ :non_billable => 0,
8
+ :task_rate => 1,
9
+ :employee_rate => 2
10
+ }
11
+
12
+ CLIENT_VIEW_TIME_CODES = {
13
+ :show_when_invoiced => 0,
14
+ :show_when_marked_billable => 1,
15
+ :never => 2
16
+ }
17
+
18
+ element :billing_code, Integer
19
+ element :client_name, String
20
+ element :client_id, Integer
21
+ element :client_type, String
22
+ element :client_view_time_code, Integer
23
+ element :completion_date, Date
24
+ element :created_on, Date
25
+ element :is_active, Boolean
26
+ element :name, String
27
+ element :rate, Float
28
+ element :start_date, Date
29
+
30
+ # Returns all employee ProjectAssignments
31
+ def employee_project_assignments(options={})
32
+ self.class.get_collection(
33
+ self.links[:assigned_employees], Cashboard::ProjectAssignment, options
34
+ )
35
+ end
36
+
37
+ end
38
+ end
@@ -0,0 +1,9 @@
1
+ module Cashboard
2
+ class ProjectAssignment < Base
3
+ element :bill_rate, Float
4
+ element :has_access, Boolean
5
+ element :pay_rate, Float
6
+ element :person_id
7
+ element :project_id
8
+ end
9
+ end
@@ -0,0 +1,45 @@
1
+ module Cashboard
2
+ class TimeEntry < Base
3
+ element :created_on, DateTime
4
+ element :description
5
+ element :invoice_line_item_id # readonly
6
+ element :is_billable, Boolean
7
+ element :is_running, Boolean # readonly
8
+ element :line_item_id
9
+ element :minutes, Integer
10
+ element :minutes_with_timer, Integer # readonly
11
+ element :person_id
12
+ element :timer_started_at, DateTime # readonly
13
+
14
+ # Starts or stops timer depending on its current state.
15
+ #
16
+ # Will return an object of Cashboard::Struct if another timer was stopped
17
+ # during this toggle operation.
18
+ #
19
+ # Will return nil if no timer was stopped.
20
+ def toggle_timer
21
+ options = self.class.merge_options()
22
+ options.merge!({:body => self.to_xml})
23
+ response = self.class.put(self.links[:toggle_timer], options)
24
+
25
+ # Raise special errors if not a success
26
+ self.class.check_status_code(response)
27
+
28
+ # Re-initialize ourselves with information from response
29
+ initialize(response.parsed_response)
30
+
31
+ if self.stopped_timer
32
+ stopped_timer = Cashboard::Struct.new(self.stopped_timer)
33
+ end
34
+
35
+ stopped_timer || nil
36
+ end
37
+
38
+ # If a TimeEntry has no invoice_line_item_id set, then it
39
+ # hasn't been included on an invoice.
40
+ def has_been_invoiced?
41
+ !self.invoice_line_item_id.blank?
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,3 @@
1
+ module Cashboard
2
+ VERSION = "1.0.1".freeze
3
+ end
data/lib/cashboard.rb ADDED
@@ -0,0 +1,75 @@
1
+ $LOAD_PATH << File.join(File.dirname(__FILE__))
2
+
3
+ require 'typecasted_open_struct'
4
+ require 'rubygems'
5
+ require 'active_support'
6
+ require 'httparty'
7
+ require 'xmlsimple'
8
+ require 'builder'
9
+
10
+ module Cashboard
11
+ # When reading the parsed hashes generated from parser we ignore these pairs.
12
+ IGNORED_XML_KEYS = ['rel', 'read_only']
13
+
14
+ class Struct < TypecastedOpenStruct
15
+ # Since we're dealing with initializing from hashes with 'content'
16
+ # keys we need to set properties based on those keys.
17
+ #
18
+ # Additionally, we do some magic to ignore attributes we don't care about.
19
+ #
20
+ # The basic concept is lifted from ostruct.rb
21
+ def initialize(hash={})
22
+ @table = {}
23
+ return unless hash
24
+ hash.each do |k,v|
25
+ # Remove keys that aren't useful for our purposes.
26
+ if v.class == Hash
27
+ Cashboard::IGNORED_XML_KEYS.each {|ignored| v.delete(ignored)}
28
+ end
29
+ # Access items based on the 'content' key inside the hash.
30
+ # Allows us to deal with all XML tags equally, even if the tags
31
+ # have attributes or not.
32
+ if v.class == Hash && v['content']
33
+ @table[k.to_sym] = v['content']
34
+ elsif v.class == Hash && v.empty?
35
+ @table[k.to_sym] = nil
36
+ else
37
+ @table[k.to_sym] = v
38
+ end
39
+ new_ostruct_member(k)
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ # Override HTTParty's XML parsing, which doesn't really work
46
+ # well for the output we receive.
47
+ class HTTParty::Parser
48
+ protected
49
+ def xml
50
+ XmlSimple.xml_in(
51
+ body,
52
+ 'KeepRoot' => false,
53
+ # Force 'link' tags into an array always
54
+ 'ForceArray' => %w(link),
55
+ # Force each item into a hash with a 'content' key for the tag value
56
+ # If we don't do this random tag attributes can screw us up.
57
+ 'ForceContent' => true
58
+ )
59
+ end
60
+ end
61
+
62
+ # After we've defined some basics let's include
63
+ # the Cashboard-rb API libraries
64
+
65
+ # Load base first or there's some issues with dependencies.
66
+ require 'cashboard/base'
67
+ require 'cashboard/behaviors/base'
68
+ require 'cashboard/behaviors/toggleable'
69
+ require 'cashboard/behaviors/lists_line_items'
70
+
71
+ library_files = Dir[File.join(File.dirname(__FILE__), 'cashboard/*.rb')]
72
+ library_files.each do |lib|
73
+ next if lib.include?('cashboard/base.rb')
74
+ require lib
75
+ end
@@ -0,0 +1,82 @@
1
+ require 'ostruct'
2
+ require 'date'
3
+ require 'time'
4
+
5
+ class Boolean; end
6
+
7
+ # A class to provide a quick and dirty way to typecast attributes
8
+ # that we specify, while silently setting the rest.
9
+ #
10
+ # Allows us to specify important attributes, but doesn't force us to
11
+ # update schema in order to deal with unexpected new properties.
12
+ #
13
+ #
14
+ # Example:
15
+ #
16
+ # class MyFoo < TypecastedOpenStruct
17
+ # element :true_false_thing, Boolean
18
+ # element :amount, Float
19
+ # end
20
+ class TypecastedOpenStruct < OpenStruct
21
+ @@elements = {}
22
+
23
+ def self.element(name, attr_type=String, options={})
24
+ element = Element.new(name, attr_type, options)
25
+ @@elements[name] = element
26
+
27
+ # Define getter to attr_typecast proper value
28
+ define_method(element.method_name) do
29
+ self.class.attr_typecast(
30
+ @table[element.method_name.to_sym],
31
+ @@elements[name].attr_type
32
+ )
33
+ end
34
+ end
35
+
36
+ # Lifted from HappyMapper. Thanks! :)
37
+ def self.attr_typecast(value, attr_type)
38
+ return value if value.kind_of?(attr_type) || value.nil?
39
+ begin
40
+ if attr_type == String then value.to_s
41
+ elsif attr_type == Float then value.to_f
42
+ elsif attr_type == Time then Time.parse(value.to_s)
43
+ elsif attr_type == Date then Date.parse(value.to_s)
44
+ elsif attr_type == DateTime then DateTime.parse(value.to_s)
45
+ elsif attr_type == Boolean then ['true', 't', '1'].include?(value.to_s.downcase)
46
+ elsif attr_type == Integer
47
+ # ganked from datamapper
48
+ value_to_i = value.to_i
49
+ if value_to_i == 0 && value != '0'
50
+ value_to_s = value.to_s
51
+ begin
52
+ Integer(value_to_s =~ /^(\d+)/ ? $1 : value_to_s)
53
+ rescue ArgumentError
54
+ nil
55
+ end
56
+ else
57
+ value_to_i
58
+ end
59
+ else
60
+ value
61
+ end
62
+ rescue
63
+ value
64
+ end
65
+ end
66
+
67
+ class Element
68
+ attr_types = [String, Float, Time, Date, DateTime, Integer, Boolean]
69
+
70
+ attr_accessor :name, :attr_type, :options, :namespace
71
+
72
+ def initialize(name, attr_type=String, options={})
73
+ self.name = name.to_s
74
+ self.attr_type = attr_type
75
+ self.options = options
76
+ end
77
+
78
+ def method_name
79
+ @method_name ||= name.tr('-', '_')
80
+ end
81
+ end
82
+ end