Module: RbInvoice

Defined in:
lib/rbinvoice.rb,
lib/rbinvoice/util.rb,
lib/rbinvoice/options.rb,
lib/rbinvoice/version.rb

Defined Under Namespace

Modules: Options, Util

Constant Summary collapse

COL_DATE =
'B'
COL_CLIENT =
'C'
COL_TASK =
'D'
COL_START_TIME =
'E'
COL_END_TIME =
'F'
COL_TOTAL_TIME =
'G'
COL_MONDAY =
'H'
COL_NOTES =
'I'
VERSION =
'0.5.0'

Class Method Summary collapse

Class Method Details

.decimal_to_interval(time) ⇒ Object



158
159
160
# File 'lib/rbinvoice.rb', line 158

def self.decimal_to_interval(time)
  "%d:%02d" % [time.to_i, (60*time) % 60]
end

.earliest_task_date(hours) ⇒ Object



31
32
33
34
# File 'lib/rbinvoice.rb', line 31

def self.earliest_task_date(hours)
  row = hours.sort_by { |row| parse_date(row[0]) }.first
  row ? row[0] : nil
end

.escape_for_latex(str) ⇒ Object



71
72
73
74
75
76
77
# File 'lib/rbinvoice.rb', line 71

def self.escape_for_latex(str)
  (str || '').gsub('&', '\\\\&').   # tricky b/c '\&' has special meaning to gsub.
    gsub('"', '\texttt{"}').
    gsub('$', '\$').
    gsub('+', '$+$').
    gsub("\n", " \\\\\\\\ \n")
end

.group_by_task(rows) ⇒ Object



173
174
175
# File 'lib/rbinvoice.rb', line 173

def self.group_by_task(rows)
  rows.group_by{|r| r[1]}
end

.hourly_breakdown(client, start_date, end_date, opts) ⇒ Object



115
116
117
# File 'lib/rbinvoice.rb', line 115

def self.hourly_breakdown(client, start_date, end_date, opts)
  hours = group_by_task(select_date_range(start_date, end_date, read_all_hours(client, opts)))
end

.interval_to_decimal(time) ⇒ Object



152
153
154
155
156
# File 'lib/rbinvoice.rb', line 152

def self.interval_to_decimal(time)
  return nil unless time
  d = Date._strptime(time, "%H:%M")
  BigDecimal.new(d[:hour] * 60 + d[:min]) / 60
end

.make_pdf(tasks, start_date, end_date, filename, opts) ⇒ Object



64
65
66
67
68
69
# File 'lib/rbinvoice.rb', line 64

def self.make_pdf(tasks, start_date, end_date, filename, opts)
  write_latex(tasks, end_date, filename, opts)
  result = system("cd \"#{File.dirname(filename)}\" && pdflatex \"#{File.basename(filename, '.pdf')}\"")
  raise "Problem running LaTeX: $?" unless result
  RbInvoice::Options::add_invoice_to_data(tasks, start_date, end_date, filename, opts) unless opts[:no_data_file]
end

.open_worksheet(spreadsheet, username, password) ⇒ Object



119
120
121
122
123
124
# File 'lib/rbinvoice.rb', line 119

def self.open_worksheet(spreadsheet, username, password)
  g = Google.new(spreadsheet, username, password)
  g.date_format = '%m/%d/%Y'
  g.default_sheet = g.sheets.first
  return g
end

.parse_date(str) ⇒ Object

TODO:

- Figure out the next invoice_number.
- Record the invoice & the new invoice_number.
- Default dir for the tex & pdf files.


26
27
28
29
# File 'lib/rbinvoice.rb', line 26

def self.parse_date(str)
  return str if str.class == Date
  Date.strptime(str, "%m/%d/%Y")
end

.read_all_hours(client, opts) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/rbinvoice.rb', line 130

def self.read_all_hours(client, opts)
  ss = nil
  begin
    ss = open_worksheet(opts[:spreadsheet], opts[:spreadsheet_user], opts[:spreadsheet_password])
  rescue Exception => e
    $stderr.puts "rbinvoice: Failed to open spreadsheet #{opts[:spreadsheet]}: #{$!}"
    exit 1
  end

  client = to_client_key(client)
  return 3.upto(ss.last_row).select { |row|
    to_client_key(ss.cell(row, COL_CLIENT) || '') == client
  }.map { |row|
    raise "Invalid task times: #{ss.cell(row, COL_START_TIME)}-#{ss.cell(row, COL_END_TIME)}" if ss.cell(row, COL_START_TIME) && ss.cell(row, COL_END_TIME) && ss.cell(row, COL_TOTAL_TIME) == '0:00:00'
    if ss.cell(row, COL_NOTES) == 'FREE'
      nil
    else
      [ss.cell(row, COL_DATE), ss.cell(row, COL_TASK), interval_to_decimal(ss.cell(row, COL_TOTAL_TIME))]
    end
  }.compact
end

.select_date_range(start_date, end_date, hours) ⇒ Object



162
163
164
165
166
167
168
169
170
171
# File 'lib/rbinvoice.rb', line 162

def self.select_date_range(start_date, end_date, hours)
  hours.select do |row|
    # puts "#{row[0].class}: #{row.join("\t")}"
    # Sometimes we get a String, sometimes a Date,
    # and changing the cell's format in the spreadsheet
    # doesn't have any effect. So do our best to support both:
    d = row[0].class == String ? parse_date(row[0]) : row[0]
    start_date <= d and d <= end_date
  end
end

.to_client_key(client) ⇒ Object



126
127
128
# File 'lib/rbinvoice.rb', line 126

def self.to_client_key(client)
  client.downcase.gsub(' ', '')
end

.write_invoices(client, start_date, end_date, filename, opts) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/rbinvoice.rb', line 36

def self.write_invoices(client, start_date, end_date, filename, opts)
  if start_date and end_date
    tasks = hourly_breakdown(client, start_date, end_date, opts)
    make_pdf(tasks, start_date, end_date, filename, opts)
  else
    # Write all the outstanding spreadsheets
    freq = RbInvoice::Options::frequency_for_client(opts[:data], client)
    last_invoice = RbInvoice::Options::last_invoice_for_client(opts[:data], client)
    hours = read_all_hours(client, opts)
    earliest_date = if last_invoice
                   last_invoice[:end_date] + 1
                 else
                   parse_date(earliest_task_date(hours))
                 end
    start_date, end_date = RbInvoice::Options::find_invoice_bounds(earliest_date, freq)
    opts[:start_date] = start_date
    opts[:end_date] = end_date
    tasks = hourly_breakdown(client, start_date, end_date, opts)
    while tasks.size > 0
      filename = RbInvoice::Options::default_out_filename(opts)
      make_pdf(tasks, start_date, end_date, filename, opts)
      start_date, end_date = RbInvoice::Options::find_invoice_bounds(end_date + 1, freq)
      tasks = hourly_breakdown(client, start_date, end_date, opts)
      opts[:invoice_number] += 1
    end
  end
end

.write_latex(tasks, invoice_date, filename, opts) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/rbinvoice.rb', line 79

def self.write_latex(tasks, invoice_date, filename, opts)
  template = File.open(opts[:template]) { |f| f.read }
  rate = opts[:rate]    # TODO: Support per-task rates
  full_name = RbInvoice::Options::full_name_for_client(opts[:data], opts, opts[:client])
  address = RbInvoice::Options::address_for_client(opts[:data], opts, opts[:client])
  description = RbInvoice::Options::description_for_client(opts[:data], opts, opts[:client])
  items = tasks.map{|task, details|
    task_total_hours = details.inject(0) {|t, row| t + row[2]}
    {
      'name' => escape_for_latex(task),
      'duration_decimal' => task_total_hours,
      'duration' => decimal_to_interval(task_total_hours),
      'price_decimal' => task_total_hours * rate,
      'price' => "%0.02f" % (task_total_hours * rate)
    }
  }


  args = Hash[
    {
      invoice_number: opts[:invoice_number],
      invoice_date: invoice_date.strftime("%d %B %Y"),
      line_items: items,
      total_duration: decimal_to_interval(items.inject(0) {|t, item| t + item['duration_decimal']}),
      total_price: "%0.02f" % items.inject(0) {|t, item| t + item['price_decimal']},
      dba: escape_for_latex(opts[:dba]),
      payment_due: opts[:payment_due],
      client_full_name: escape_for_latex(full_name),
      client_address: escape_for_latex(address),
      client_description: escape_for_latex(description),
    }.map{|k, v| [k.to_s, v]}
  ]
  latex = Liquid::Template.parse(template).render args
  File.open("#{filename.gsub(/\.pdf$/, '')}.tex", 'w') { |f| f.write(latex) }
end