cashboard 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
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