rbinvoice 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,18 @@
1
+ source "http://rubygems.org"
2
+ # Add dependencies required to use your gem here.
3
+ # Example:
4
+ # gem "activesupport", ">= 2.3.5"
5
+
6
+ gem 'trollop'
7
+ gem 'roo'
8
+ gem 'prawn'
9
+ gem 'liquid'
10
+
11
+ # Add dependencies to develop your gem here.
12
+ # Include everything needed to run rake, tests, features, etc.
13
+ group :development do
14
+ gem "rspec"
15
+ gem "bundler"
16
+ gem "jeweler"
17
+ gem "simplecov", ">= 0"
18
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,87 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ Ascii85 (1.0.1)
5
+ choice (0.1.6)
6
+ diff-lcs (1.1.3)
7
+ faraday (0.8.1)
8
+ multipart-post (~> 1.1)
9
+ git (1.2.5)
10
+ google-spreadsheet-ruby (0.3.0)
11
+ google_drive (>= 0.3.0)
12
+ google_drive (0.3.1)
13
+ nokogiri (>= 1.4.4, != 1.5.2, != 1.5.1)
14
+ oauth (>= 0.3.6)
15
+ oauth2 (>= 0.5.0)
16
+ httpauth (0.1)
17
+ jeweler (1.8.4)
18
+ bundler (~> 1.0)
19
+ git (>= 1.2.5)
20
+ rake
21
+ rdoc
22
+ json (1.7.4)
23
+ jwt (0.1.5)
24
+ multi_json (>= 1.0)
25
+ liquid (2.3.0)
26
+ log4r (1.1.10)
27
+ multi_json (1.3.6)
28
+ multipart-post (1.1.5)
29
+ nokogiri (1.5.5)
30
+ oauth (0.4.6)
31
+ oauth2 (0.8.0)
32
+ faraday (~> 0.8)
33
+ httpauth (~> 0.1)
34
+ jwt (~> 0.1.4)
35
+ multi_json (~> 1.0)
36
+ rack (~> 1.2)
37
+ pdf-reader (1.1.1)
38
+ Ascii85 (~> 1.0.0)
39
+ ruby-rc4
40
+ prawn (0.12.0)
41
+ pdf-reader (>= 0.9.0)
42
+ ttfunk (~> 1.0.2)
43
+ rack (1.4.1)
44
+ rake (0.9.2.2)
45
+ rdoc (3.12)
46
+ json (~> 1.4)
47
+ roo (1.10.1)
48
+ choice (>= 0.1.4)
49
+ google-spreadsheet-ruby (>= 0.1.5)
50
+ nokogiri (>= 1.5.0)
51
+ rubyzip (>= 0.9.4)
52
+ spreadsheet (> 0.6.4)
53
+ todonotes (>= 0.1.0)
54
+ rspec (2.11.0)
55
+ rspec-core (~> 2.11.0)
56
+ rspec-expectations (~> 2.11.0)
57
+ rspec-mocks (~> 2.11.0)
58
+ rspec-core (2.11.1)
59
+ rspec-expectations (2.11.2)
60
+ diff-lcs (~> 1.1.3)
61
+ rspec-mocks (2.11.1)
62
+ ruby-ole (1.2.11.4)
63
+ ruby-rc4 (0.1.5)
64
+ rubyzip (0.9.9)
65
+ simplecov (0.6.4)
66
+ multi_json (~> 1.0)
67
+ simplecov-html (~> 0.5.3)
68
+ simplecov-html (0.5.3)
69
+ spreadsheet (0.7.3)
70
+ ruby-ole (>= 1.0)
71
+ todonotes (0.1.0)
72
+ log4r
73
+ trollop (1.16.2)
74
+ ttfunk (1.0.3)
75
+
76
+ PLATFORMS
77
+ ruby
78
+
79
+ DEPENDENCIES
80
+ bundler
81
+ jeweler
82
+ liquid
83
+ prawn
84
+ roo
85
+ rspec
86
+ simplecov
87
+ trollop
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Paul A. Jungwirth
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/NOTES ADDED
@@ -0,0 +1,12 @@
1
+ To create a new gem:
2
+ - `rake version` to bump the version number. Also:
3
+ - `rake version:bump:major`
4
+ - `rake version:bump:minor`
5
+ - `rake version:bump:patch`
6
+ - `rake gemspec` to use jeweler to autogenerate the gemspec based on the Rakefile.
7
+ - `gem build rbinvoice.gemspec` to build a rbinvoice-$version.gem file.
8
+ - `gem install ./rbinvoice-$version.gem` to install it locally for testing.
9
+ - OR `rake install` to build and install.
10
+ - `gem push rbinvoice-$version.gem` to push it to RubyGems.org.
11
+ - OR `rake release` to release the gem.
12
+
data/README.md ADDED
@@ -0,0 +1,6 @@
1
+ rbinvoice
2
+ =========
3
+
4
+ rbinvoice lets you generate PDF invoices from a Google Spreadsheet.
5
+ It's pretty obscure; you probably haven't heard of it.
6
+
data/Rakefile ADDED
@@ -0,0 +1,60 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ Jeweler::Tasks.new do |gem|
16
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
17
+ gem.name = "rbinvoice"
18
+ gem.homepage = "http://github.com/pjungwir/rbinvoice"
19
+ gem.license = "MIT"
20
+ gem.summary = 'Used to invoice my clients'
21
+ gem.description = <<-EOT
22
+ Reads hours from a Google Spreadsheet and generates a PDF invoice.
23
+ EOT
24
+ gem.email = "pj@illuminatedcomputing.com"
25
+ gem.authors = ["Paul A. Jungwirth"]
26
+ gem.executables << 'rbinvoice'
27
+ # dependencies defined in Gemfile
28
+ end
29
+ Jeweler::RubygemsDotOrgTasks.new
30
+
31
+ require 'rspec/core'
32
+ require 'rspec/core/rake_task'
33
+ RSpec::Core::RakeTask.new(:spec) do |spec|
34
+ spec.pattern = FileList['spec/**/*_spec.rb']
35
+ end
36
+
37
+ =begin
38
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
39
+ spec.pattern = 'spec/**/*_spec.rb'
40
+ spec.rcov = true
41
+ end
42
+ =end
43
+
44
+ task :default => :spec
45
+
46
+ require 'rdoc/task'
47
+ Rake::RDocTask.new do |rdoc|
48
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
49
+
50
+ rdoc.rdoc_dir = 'rdoc'
51
+ rdoc.title = "rbinvoice #{version}"
52
+ rdoc.rdoc_files.include('README*')
53
+ rdoc.rdoc_files.include('lib/**/*.rb')
54
+ end
55
+
56
+ task :run, [] => [] do |t, args|
57
+ require 'rbinvoice'
58
+ RbInvoice::write_invoice(*RbInvoice::Options::parse_command_line(%w{okvenue}))
59
+ end
60
+
data/TODO ADDED
@@ -0,0 +1,10 @@
1
+ - Write the README
2
+ - overview
3
+ - options
4
+ - spreadsheet format
5
+ - .rbinvoice
6
+ - .rbinvoicerc
7
+ - LaTeX+liquid template
8
+ - more tests
9
+ - print the pdflatex results to std{out,err}
10
+
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.0
data/bin/rbinvoice ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require 'rbinvoice'
3
+ RbInvoice::write_invoices(*RbInvoice::Options::parse_command_line(ARGV))
@@ -0,0 +1,271 @@
1
+ require 'yaml'
2
+ require 'trollop'
3
+ require 'rbinvoice/util'
4
+
5
+ module RbInvoice
6
+ module Options
7
+
8
+ # This is a method rather than a constant
9
+ # so that we don't evaulate ENV['HOME']
10
+ # until it's called. That makes it possible
11
+ # for tests to set ENV['HOME'] before running the code.
12
+ def self.default_rc_file
13
+ File.join(ENV['HOME'] || '.', '.rbinvoicerc')
14
+ end
15
+
16
+ # This is a method rather than a constant
17
+ # so that we don't evaulate ENV['HOME']
18
+ # until it's called. That makes it possible
19
+ # for tests to set ENV['HOME'] before running the code.
20
+ def self.default_data_file
21
+ File.join(ENV['HOME'] || '.', '.rbinvoice')
22
+ end
23
+
24
+ def self.default_template_filename
25
+ File.join(File.dirname(__FILE__), '..', '..', 'templates', 'invoice.tex.liquid')
26
+ end
27
+
28
+ def self.write_data_file(opts)
29
+ # Add the new invoice to the list of client invoices.
30
+ File.open(opts[:data_file], 'w') { |f| f.puts YAML::dump(opts[:data]) }
31
+ end
32
+
33
+ def self.read_data_file(opts)
34
+ if File.exist?(opts[:data_file])
35
+ ret = parse_data_file(File.read(opts[:data_file]), opts)
36
+ client = data_for_client(ret, opts[:client])
37
+ opts[:rate] = client[:rate] if client
38
+ return ret
39
+ else
40
+ return {}
41
+ end
42
+ end
43
+
44
+ def self.parse_data_file(text, opts)
45
+ text ? RbInvoice::Util::read_with_yaml(text) : {}
46
+ end
47
+
48
+ def self.read_rc_file(opts)
49
+ Trollop::die :rcfile, "doesn't exist" if opts[:rcfile] and not File.exist?(opts[:rcfile])
50
+
51
+ # Don't apply the default until now
52
+ # so we know if the user requested one specifically or not:
53
+ opts[:rcfile] ||= default_rc_file
54
+
55
+ if File.exist?(opts[:rcfile])
56
+ return parse_rc_file(File.read(opts[:rcfile]), opts)
57
+ else
58
+ return opts
59
+ end
60
+ end
61
+
62
+ def self.parse_rc_file(text, opts)
63
+ rc = (text ? RbInvoice::Util::read_with_yaml(text) : {})
64
+ %w{spreadsheet spreadsheet_user spreadsheet_password out_directory}.each do |key|
65
+ key = key.to_sym
66
+ opts[key] ||= rc[key]
67
+ end
68
+ return opts
69
+ end
70
+
71
+ # TODO: Allow per-client settings to override the global setting
72
+ def self.default_out_directory(opts)
73
+ opts[:out_directory] || '.'
74
+ end
75
+
76
+ # Looks in ~/.rbinvoice
77
+ def self.default_out_filename(opts)
78
+ if opts[:client] and opts[:invoice_number]
79
+ File.join(default_out_directory(opts), "invoice-#{opts[:invoice_number]}-#{opts[:client]}.pdf")
80
+ else
81
+ nil
82
+ end
83
+ end
84
+
85
+ def self.first_day_of_the_month(d)
86
+ Date.new(d.year, d.month, 1)
87
+ end
88
+
89
+ def self.last_day_of_the_month(d)
90
+ n = d.next_month
91
+ Date.new(d.year, d.month, (Date.new(n.year, n.month, 1) - 1).day)
92
+ end
93
+
94
+ def self.semimonth_end(d)
95
+ if d.day <= 15
96
+ Date.new(d.year, d.month, 15)
97
+ else
98
+ Date.new(d.year, d.month, last_day_of_the_month(d).day)
99
+ end
100
+ end
101
+
102
+ def self.semimonth_start(d)
103
+ if d.day <= 15
104
+ first_day_of_the_month(d)
105
+ else
106
+ Date.new(d.year, d.month, 15)
107
+ end
108
+ end
109
+
110
+ def self.week_start(d)
111
+ # Assumes the week starts on a Sunday:
112
+ d - d.wday
113
+ end
114
+
115
+ def self.week_end(d)
116
+ week_start(d) + 7
117
+ end
118
+
119
+ # second_half_of_biweek - indicates that `d` is from the second half of the biweek.
120
+ # If false (the default), we assume `d` is from the first half.
121
+ def self.find_invoice_bounds(d, freq, second_half_of_biweek=false)
122
+ case freq.to_sym
123
+ when :weekly
124
+ return week_start(d), week_end(d)
125
+ when :biweekly
126
+ if second_half_of_biweek
127
+ return week_start(d) - 7, week_end(d)
128
+ else
129
+ return week_start(d), week_end(d) + 7
130
+ end
131
+ when :semimonthly
132
+ return semimonth_start(d), semimonth_end(d)
133
+ when :monthly
134
+ return first_day_of_the_month(d), last_day_of_the_month(d)
135
+ else
136
+ raise "Unknown frequency: #{freq}"
137
+ end
138
+ end
139
+
140
+ def self.add_new_client_data(client, data)
141
+ h = {
142
+ 'name' => client,
143
+ 'key' => client,
144
+ 'invoices' => []
145
+ }
146
+ (data[:clients] ||= []) << h
147
+ return h
148
+ end
149
+
150
+ def self.add_invoice_to_data(tasks, start_date, end_date, filename, opts)
151
+ data = opts[:data]
152
+ client = data_for_client(data, opts[:client]) || add_new_client_data(opts[:client], data)
153
+ (client[:invoices] ||= []) << {
154
+ 'id' => opts[:invoice_number],
155
+ 'start_date' => start_date.strftime("%Y-%m-%d"),
156
+ 'end_date' => end_date.strftime("%Y-%m-%d"),
157
+ 'filename' => filename
158
+ }
159
+ if not data[:last_invoice] or opts[:invoice_number] > data[:last_invoice]
160
+ data[:last_invoice] = opts[:invoice_number]
161
+ end
162
+ write_data_file(opts)
163
+ end
164
+
165
+ def self.all_clients(data)
166
+ data[:clients] || []
167
+ end
168
+
169
+ def self.last_invoice_number(data)
170
+ data[:last_invoice]
171
+ end
172
+
173
+ def self.data_for_client(data, client)
174
+ all_clients(data).select{|x| x[:key] == RbInvoice::to_client_key(client)}.first
175
+ end
176
+
177
+ def self.key_for_client(data, client, key)
178
+ d = data_for_client(data, client)
179
+ d = d ? d[key] : nil
180
+ end
181
+
182
+ def self.frequncy_for_client(data, client)
183
+ key_for_client(data, client, :frequency)
184
+ end
185
+
186
+ def self.invoices_for_client(data, client)
187
+ key_for_client(data, client, :invoices) || []
188
+ end
189
+
190
+ def self.last_invoice_for_client(data, client)
191
+ invoices_for_client(data, client).sort_by{|x| x[:end_date]}.last
192
+ end
193
+
194
+ def self.frequency_for_client(data, client)
195
+ key_for_client(data, client, :frequency)
196
+ end
197
+
198
+ def self.parse_command_line(argv)
199
+ opts = Trollop::options(argv) do
200
+ version "rbinvoice 0.1.0 (c) 2012 Paul A. Jungwirth"
201
+ banner <<-EOH
202
+ USAGE: rbinvoice [options] <client> [filename]
203
+ EOH
204
+ opt :help, "Show a help message"
205
+
206
+ opt :rcfile, "Use an rc file other than ~/.rbinvoicerc", :short => '-r'
207
+ opt :no_rcfile, "Don't read an rc file", :default => false, :short => '-R'
208
+
209
+ opt :data_file, "Use a data file other than ~/.rbinvoice", :default => RbInvoice::Options.default_data_file, :short => '-d'
210
+ opt :no_data_file, "Don't read or write to a data file", :default => false, :short => '-D'
211
+
212
+ opt :invoice_number, "Use a specific invoice number", :type => :int, :short => '-n'
213
+ opt :no_write_invoice_number, "Record the invoice number", :default => false, :short => '-N'
214
+
215
+ opt :spreadsheet, "Read the given spreadsheet URL", :type => :string, :short => '-u'
216
+ opt :start_date, "Date to begin the invoice (yyyy-mm-dd)", :type => :string
217
+ opt :end_date, "Date to end the invoice (yyyy-mm-dd)", :type => :string
218
+
219
+ opt :template, "Use the given liquid template", :type => :string, :default => RbInvoice::Options::default_template_filename
220
+ end
221
+ Trollop::die "client must be given" unless argv.size > 0
222
+ opts[:client] = argv.shift
223
+
224
+ read_rc_file(opts) unless opts[:no_rcfile]
225
+ opts[:data] = opts[:no_data_file] ? {} : read_data_file(opts)
226
+
227
+ if not opts[:invoice_number] and not last_invoice_number(opts[:data])
228
+ Trollop::die "Can't determine invoice number"
229
+ end
230
+ opts[:invoice_number] ||= last_invoice_number(opts[:data]) + 1
231
+
232
+ Trollop::die "can't determine hourly spreadsheet" unless opts[:spreadsheet]
233
+
234
+ opts[:out_filename] = argv.shift
235
+ if not opts[:out_filename]
236
+ opts[:out_filename] = default_out_filename(opts)
237
+ opts[:used_default_out_filename] = true # TODO if this is set and not quiet, then print the name of the file we wrote to: "Wrote invoice to #{out_filename}"
238
+ end
239
+ Trollop::die "can't infer output filename; please provide one" unless opts[:out_filename]
240
+
241
+ # opts[:start_date] = '2012-07-15'
242
+ # opts[:end_date] = '2012-07-31'
243
+ opts[:start_date] = Date.strptime(opts[:start_date], "%Y-%m-%d") if opts[:start_date]
244
+ opts[:end_date] = Date.strptime(opts[:end_date], "%Y-%m-%d") if opts[:end_date]
245
+
246
+ # Read the list of past invoices.
247
+ # If there are none, assume there is only one invoice to do.
248
+
249
+ jobs = []
250
+
251
+ last_invoice = last_invoice_for_client(opts[:data], opts[:client])
252
+ if opts[:end_date] and opts[:start_date]
253
+ # just do it, regardless of frequency.
254
+ elsif opts[:end_date] or opts[:start_date]
255
+ freq = frequency_for_client(opts[:data], opts[:client])
256
+ if freq
257
+ opts[:start_date], opts[:end_date] = find_invoice_bounds(opts[:start_date] || opts[:end_date], freq, !!opts[:end_date])
258
+ else
259
+ Trollop::die "can't determine invoice range without frequency"
260
+ end
261
+ else
262
+ # Do all pending invoices (leave start_date and end_date nil).
263
+ end
264
+
265
+ # return jobs
266
+
267
+ return opts[:client], opts[:start_date], opts[:end_date], opts[:out_filename], opts
268
+ end
269
+
270
+ end
271
+ end
@@ -0,0 +1,29 @@
1
+ module RbInvoice
2
+ module Util
3
+
4
+ def self.symbolize_array(arr)
5
+ arr.map{|x|
6
+ case x
7
+ when Hash; symbolize_hash(x)
8
+ when Array; symbolize_array(x)
9
+ else x
10
+ end
11
+ }
12
+ end
13
+
14
+ def self.symbolize_hash(h)
15
+ h.each_with_object({}) {|(k,v), h|
16
+ h[k.to_sym] = case v
17
+ when Hash; symbolize_hash(v)
18
+ when Array; symbolize_array(v)
19
+ else; v
20
+ end
21
+ }
22
+ end
23
+
24
+ def self.read_with_yaml(text)
25
+ symbolize_hash(YAML::load(text) || {})
26
+ end
27
+
28
+ end
29
+ end
data/lib/rbinvoice.rb ADDED
@@ -0,0 +1,157 @@
1
+ require 'date'
2
+ require 'bigdecimal'
3
+ require 'trollop'
4
+ # require 'prawn'
5
+
6
+ require 'rbinvoice/options'
7
+ require 'roo'
8
+ require 'liquid'
9
+
10
+ module RbInvoice
11
+
12
+ COL_DATE = 'B'
13
+ COL_CLIENT = 'C'
14
+ COL_TASK = 'D'
15
+ COL_START_TIME = 'E'
16
+ COL_END_TIME = 'F'
17
+ COL_TOTAL_TIME = 'G'
18
+
19
+ # TODO:
20
+ # - Figure out the next invoice_number.
21
+ # - Record the invoice & the new invoice_number.
22
+ # - Default dir for the tex & pdf files.
23
+
24
+ def self.parse_date(str)
25
+ return str if str.class == Date
26
+ Date.strptime(str, "%m/%d/%Y")
27
+ end
28
+
29
+ def self.earliest_task_date(hours)
30
+ row = hours.sort_by { |row| parse_date(row[0]) }.first
31
+ row ? row[0] : nil
32
+ end
33
+
34
+ def self.write_invoices(client, start_date, end_date, filename, opts)
35
+ if start_date and end_date
36
+ tasks = hourly_breakdown(client, start_date, end_date, opts)
37
+ make_pdf(tasks, start_date, end_date, filename, opts)
38
+ else
39
+ # Write all the outstanding spreadsheets
40
+ freq = RbInvoice::Options::frequency_for_client(opts[:data], client)
41
+ last_invoice = RbInvoice::Options::last_invoice_for_client(opts[:data], client)
42
+ hours = read_all_hours(client, opts)
43
+ earliest_date = if last_invoice
44
+ last_invoice[:end_date] + 1
45
+ else
46
+ parse_date(earliest_task_date)
47
+ end
48
+ start_date, end_date = RbInvoice::Options::find_invoice_bounds(earliest_date, freq)
49
+ tasks = hourly_breakdown(client, start_date, end_date, opts)
50
+ while tasks.size > 0
51
+ filename = RbInvoice::Options::default_out_filename(opts)
52
+ make_pdf(tasks, start_date, end_date, filename, opts)
53
+ start_date, end_date = RbInvoice::Options::find_invoice_bounds(end_date + 1, freq)
54
+ tasks = hourly_breakdown(client, start_date, end_date, opts)
55
+ opts[:invoice_number] += 1
56
+ end
57
+ end
58
+ end
59
+
60
+ def self.make_pdf(tasks, start_date, end_date, filename, opts)
61
+ write_latex(tasks, end_date, filename, opts)
62
+ `cd "#{File.dirname(filename)}" && pdflatex "#{File.basename(filename, '.pdf')}"`
63
+ RbInvoice::Options::add_invoice_to_data(tasks, start_date, end_date, filename, opts) unless opts[:no_data_file]
64
+ end
65
+
66
+ def self.escape_for_latex(str)
67
+ str.gsub('&', '\\\\&'). # tricky b/c '\&' has special meaning to gsub.
68
+ gsub('"', '\"').
69
+ gsub('$', '\$')
70
+ end
71
+
72
+ def self.write_latex(tasks, invoice_date, filename, opts)
73
+ template = File.open(opts[:template]) { |f| f.read }
74
+ rate = opts[:rate] # TODO: Support per-task rates
75
+ items = tasks.map{|task, details|
76
+ task_total_hours = details.inject(0) {|t, row| t + row[2]}
77
+ {
78
+ 'name' => escape_for_latex(task),
79
+ 'duration_decimal' => task_total_hours,
80
+ 'duration' => decimal_to_interval(task_total_hours),
81
+ 'price_decimal' => task_total_hours * rate,
82
+ 'price' => "%0.02f" % (task_total_hours * rate)
83
+ }
84
+ }
85
+
86
+
87
+ args = Hash[
88
+ {
89
+ invoice_number: opts[:invoice_number],
90
+ invoice_date: invoice_date.strftime("%d %B %Y"),
91
+ line_items: items,
92
+ total_duration: decimal_to_interval(items.inject(0) {|t, item| t + item['duration_decimal']}),
93
+ total_price: "%0.02f" % items.inject(0) {|t, item| t + item['price_decimal']},
94
+ }.map{|k, v| [k.to_s, v]}
95
+ ]
96
+ latex = Liquid::Template.parse(template).render args
97
+ File.open("#{filename.gsub(/\.pdf$/, '')}.tex", 'w') { |f| f.write(latex) }
98
+ end
99
+
100
+ def self.hourly_breakdown(client, start_date, end_date, opts)
101
+ hours = group_by_task(select_date_range(start_date, end_date, read_all_hours(client, opts)))
102
+ end
103
+
104
+ def self.open_worksheet(spreadsheet, username, password)
105
+ g = Google.new(spreadsheet, username, password)
106
+ g.date_format = '%m/%d/%Y'
107
+ g.default_sheet = g.sheets.first
108
+ return g
109
+ end
110
+
111
+ def self.to_client_key(client)
112
+ client.downcase.gsub(' ', '')
113
+ end
114
+
115
+ def self.read_all_hours(client, opts)
116
+ ss = nil
117
+ begin
118
+ ss = open_worksheet(opts[:spreadsheet], opts[:spreadsheet_user], opts[:spreadsheet_password])
119
+ rescue Exception => e
120
+ $stderr.puts "rbinvoice: Failed to open spreadsheet #{opts[:spreadsheet]}: #{$!}"
121
+ exit 1
122
+ end
123
+
124
+ client = to_client_key(client)
125
+ return 3.upto(ss.last_row).select { |row|
126
+ to_client_key(ss.cell(row, COL_CLIENT) || '') == client
127
+ }.map { |row|
128
+ [ss.cell(row, COL_DATE), ss.cell(row, COL_TASK), interval_to_decimal(ss.cell(row, COL_TOTAL_TIME))]
129
+ }
130
+ end
131
+
132
+ def self.interval_to_decimal(time)
133
+ return nil unless time
134
+ d = Date._strptime(time, "%H:%M")
135
+ BigDecimal.new(d[:hour] * 60 + d[:min]) / 60
136
+ end
137
+
138
+ def self.decimal_to_interval(time)
139
+ "%d:%02d" % [time.to_i, (60*time) % 60]
140
+ end
141
+
142
+ def self.select_date_range(start_date, end_date, hours)
143
+ hours.select do |row|
144
+ # puts "#{row[0].class}: #{row.join("\t")}"
145
+ # Sometimes we get a String, sometimes a Date,
146
+ # and changing the cell's format in the spreadsheet
147
+ # doesn't have any effect. So do our best to support both:
148
+ d = row[0].class == String ? parse_date(row[0]) : row[0]
149
+ start_date <= d and d <= end_date
150
+ end
151
+ end
152
+
153
+ def self.group_by_task(rows)
154
+ rows.group_by{|r| r[1]}
155
+ end
156
+
157
+ end
data/rbinvoice.gemspec ADDED
@@ -0,0 +1,76 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "rbinvoice"
8
+ s.version = "0.2.0"
9
+
10
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
11
+ s.authors = ["Paul A. Jungwirth"]
12
+ s.date = "2012-08-07"
13
+ s.description = " Reads hours from a Google Spreadsheet and generates a PDF invoice.\n"
14
+ s.email = "pj@illuminatedcomputing.com"
15
+ s.executables = ["rbinvoice", "rbinvoice"]
16
+ s.extra_rdoc_files = [
17
+ "LICENSE.txt",
18
+ "README.md",
19
+ "TODO"
20
+ ]
21
+ s.files = [
22
+ "Gemfile",
23
+ "Gemfile.lock",
24
+ "LICENSE.txt",
25
+ "NOTES",
26
+ "README.md",
27
+ "Rakefile",
28
+ "VERSION",
29
+ "bin/rbinvoice",
30
+ "lib/rbinvoice.rb",
31
+ "lib/rbinvoice/options.rb",
32
+ "lib/rbinvoice/util.rb",
33
+ "rbinvoice.gemspec",
34
+ "spec/options_spec.rb",
35
+ "templates/invoice.tex.liquid"
36
+ ]
37
+ s.homepage = "http://github.com/pjungwir/rbinvoice"
38
+ s.licenses = ["MIT"]
39
+ s.require_paths = ["lib"]
40
+ s.rubygems_version = "1.8.24"
41
+ s.summary = "Used to invoice my clients"
42
+
43
+ if s.respond_to? :specification_version then
44
+ s.specification_version = 3
45
+
46
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
47
+ s.add_runtime_dependency(%q<trollop>, [">= 0"])
48
+ s.add_runtime_dependency(%q<roo>, [">= 0"])
49
+ s.add_runtime_dependency(%q<prawn>, [">= 0"])
50
+ s.add_runtime_dependency(%q<liquid>, [">= 0"])
51
+ s.add_development_dependency(%q<rspec>, [">= 0"])
52
+ s.add_development_dependency(%q<bundler>, [">= 0"])
53
+ s.add_development_dependency(%q<jeweler>, [">= 0"])
54
+ s.add_development_dependency(%q<simplecov>, [">= 0"])
55
+ else
56
+ s.add_dependency(%q<trollop>, [">= 0"])
57
+ s.add_dependency(%q<roo>, [">= 0"])
58
+ s.add_dependency(%q<prawn>, [">= 0"])
59
+ s.add_dependency(%q<liquid>, [">= 0"])
60
+ s.add_dependency(%q<rspec>, [">= 0"])
61
+ s.add_dependency(%q<bundler>, [">= 0"])
62
+ s.add_dependency(%q<jeweler>, [">= 0"])
63
+ s.add_dependency(%q<simplecov>, [">= 0"])
64
+ end
65
+ else
66
+ s.add_dependency(%q<trollop>, [">= 0"])
67
+ s.add_dependency(%q<roo>, [">= 0"])
68
+ s.add_dependency(%q<prawn>, [">= 0"])
69
+ s.add_dependency(%q<liquid>, [">= 0"])
70
+ s.add_dependency(%q<rspec>, [">= 0"])
71
+ s.add_dependency(%q<bundler>, [">= 0"])
72
+ s.add_dependency(%q<jeweler>, [">= 0"])
73
+ s.add_dependency(%q<simplecov>, [">= 0"])
74
+ end
75
+ end
76
+
@@ -0,0 +1,139 @@
1
+ require 'rbinvoice'
2
+
3
+ describe RbInvoice::Options do
4
+
5
+ before :each do
6
+ @tmpdir = "/tmp/rbinvoice-test-#{$$}"
7
+ FileUtils.rm_rf @tmpdir
8
+ FileUtils.mkdir_p @tmpdir
9
+ ENV['HOME'] = @tmpdir
10
+ end
11
+
12
+ after :each do
13
+ FileUtils.rm_rf @tmpdir
14
+ end
15
+
16
+ it "should compute the first day of the month" do
17
+ RbInvoice::Options::first_day_of_the_month(Date.new(2011, 3, 5)).should == Date.new(2011, 3,1)
18
+ RbInvoice::Options::first_day_of_the_month(Date.new(2011, 3,31)).should == Date.new(2011, 3,1)
19
+ RbInvoice::Options::first_day_of_the_month(Date.new(2011, 3, 1)).should == Date.new(2011, 3,1)
20
+ RbInvoice::Options::first_day_of_the_month(Date.new(2011, 2,28)).should == Date.new(2011, 2,1)
21
+ RbInvoice::Options::first_day_of_the_month(Date.new(2012, 2,29)).should == Date.new(2012, 2,1)
22
+ RbInvoice::Options::first_day_of_the_month(Date.new(2012, 1, 5)).should == Date.new(2012, 1,1)
23
+ RbInvoice::Options::first_day_of_the_month(Date.new(2011,12, 5)).should == Date.new(2011,12,1)
24
+ end
25
+
26
+ it "should compute the last day of the month" do
27
+ RbInvoice::Options::last_day_of_the_month(Date.new(2011, 3, 5)).should == Date.new(2011, 3,31)
28
+ RbInvoice::Options::last_day_of_the_month(Date.new(2011, 3,31)).should == Date.new(2011, 3,31)
29
+ RbInvoice::Options::last_day_of_the_month(Date.new(2011, 3, 1)).should == Date.new(2011, 3,31)
30
+ RbInvoice::Options::last_day_of_the_month(Date.new(2011, 4, 8)).should == Date.new(2011, 4,30)
31
+ RbInvoice::Options::last_day_of_the_month(Date.new(2011, 2,28)).should == Date.new(2011, 2,28)
32
+ RbInvoice::Options::last_day_of_the_month(Date.new(2012, 2,29)).should == Date.new(2012, 2,29)
33
+ RbInvoice::Options::last_day_of_the_month(Date.new(2011, 2,19)).should == Date.new(2011, 2,28)
34
+ RbInvoice::Options::last_day_of_the_month(Date.new(2012, 2,19)).should == Date.new(2012, 2,29)
35
+ RbInvoice::Options::last_day_of_the_month(Date.new(2012, 1, 5)).should == Date.new(2012, 1,31)
36
+ RbInvoice::Options::last_day_of_the_month(Date.new(2011,12, 5)).should == Date.new(2011,12,31)
37
+ end
38
+
39
+ it "should compute the semimonth end date" do
40
+ RbInvoice::Options::semimonth_end(Date.new(2011, 3, 5)).should == Date.new(2011, 3,15)
41
+ RbInvoice::Options::semimonth_end(Date.new(2011, 3,21)).should == Date.new(2011, 3,31)
42
+ RbInvoice::Options::semimonth_end(Date.new(2011, 3,31)).should == Date.new(2011, 3,31)
43
+ RbInvoice::Options::semimonth_end(Date.new(2011, 3, 1)).should == Date.new(2011, 3,15)
44
+ RbInvoice::Options::semimonth_end(Date.new(2011, 4, 8)).should == Date.new(2011, 4,15)
45
+ RbInvoice::Options::semimonth_end(Date.new(2011, 2,28)).should == Date.new(2011, 2,28)
46
+ RbInvoice::Options::semimonth_end(Date.new(2012, 2,28)).should == Date.new(2012, 2,29)
47
+ RbInvoice::Options::semimonth_end(Date.new(2012, 2,29)).should == Date.new(2012, 2,29)
48
+ RbInvoice::Options::semimonth_end(Date.new(2011, 2,19)).should == Date.new(2011, 2,28)
49
+ RbInvoice::Options::semimonth_end(Date.new(2012, 2,19)).should == Date.new(2012, 2,29)
50
+ RbInvoice::Options::semimonth_end(Date.new(2012, 1, 5)).should == Date.new(2012, 1,15)
51
+ RbInvoice::Options::semimonth_end(Date.new(2011,12, 1)).should == Date.new(2011,12,15)
52
+ RbInvoice::Options::semimonth_end(Date.new(2011,12, 5)).should == Date.new(2011,12,15)
53
+ RbInvoice::Options::semimonth_end(Date.new(2011,12,15)).should == Date.new(2011,12,15)
54
+ RbInvoice::Options::semimonth_end(Date.new(2011,12,25)).should == Date.new(2011,12,31)
55
+ end
56
+
57
+ it "should compute the semimonth start date" do
58
+ RbInvoice::Options::semimonth_start(Date.new(2011, 3, 5)).should == Date.new(2011, 3, 1)
59
+ RbInvoice::Options::semimonth_start(Date.new(2011, 3,21)).should == Date.new(2011, 3,15)
60
+ RbInvoice::Options::semimonth_start(Date.new(2011, 3,31)).should == Date.new(2011, 3,15)
61
+ RbInvoice::Options::semimonth_start(Date.new(2011, 3, 1)).should == Date.new(2011, 3, 1)
62
+ RbInvoice::Options::semimonth_start(Date.new(2011, 4, 8)).should == Date.new(2011, 4, 1)
63
+ RbInvoice::Options::semimonth_start(Date.new(2011, 2,28)).should == Date.new(2011, 2,15)
64
+ RbInvoice::Options::semimonth_start(Date.new(2012, 2,28)).should == Date.new(2012, 2,15)
65
+ RbInvoice::Options::semimonth_start(Date.new(2012, 2,29)).should == Date.new(2012, 2,15)
66
+ RbInvoice::Options::semimonth_start(Date.new(2011, 2,19)).should == Date.new(2011, 2,15)
67
+ RbInvoice::Options::semimonth_start(Date.new(2012, 2,19)).should == Date.new(2012, 2,15)
68
+ RbInvoice::Options::semimonth_start(Date.new(2012, 1, 5)).should == Date.new(2012, 1, 1)
69
+ RbInvoice::Options::semimonth_start(Date.new(2011,12, 1)).should == Date.new(2011,12, 1)
70
+ RbInvoice::Options::semimonth_start(Date.new(2011,12, 5)).should == Date.new(2011,12, 1)
71
+ RbInvoice::Options::semimonth_start(Date.new(2011,12,15)).should == Date.new(2011,12, 1)
72
+ RbInvoice::Options::semimonth_start(Date.new(2011,12,25)).should == Date.new(2011,12,15)
73
+ end
74
+
75
+ it "should compute the previous semimonth" do
76
+ end
77
+
78
+ it "should have good defaults" do
79
+ ret = RbInvoice::Options::parse_command_line(%w{--spreadsheet=foo --invoice-number=5 my-client outfile})
80
+ (client, start_date, end_date, out_filename, opts) = RbInvoice::Options::parse_command_line(%w{--spreadsheet=foo --invoice-number=5 my-client outfile})
81
+ client.should == 'my-client'
82
+ out_filename.should == 'outfile'
83
+
84
+ opts[:no_rcfile].should == false
85
+ opts[:rcfile].should == "#{@tmpdir}/.rbinvoicerc"
86
+ opts[:no_data_file].should == false
87
+ opts[:data_file].should == "#{@tmpdir}/.rbinvoice"
88
+ opts[:no_write_invoice_number].should == false
89
+ end
90
+
91
+ it "should require an output filename if it can't infer one" do
92
+ lambda {
93
+ RbInvoice::Options::parse_command_line(%w{--spreadsheet=foo my-client})
94
+ }.should raise_error SystemExit
95
+ end
96
+
97
+ it "should require a spreadsheet URL if it can't find one in .rbinvoicerc" do
98
+ lambda {
99
+ RbInvoice::Options::parse_command_line(%w{--invoice-number=5 my-client outfile})
100
+ }.should raise_error SystemExit
101
+ end
102
+
103
+ it "should require an invoice number if it can't infer one" do
104
+ lambda {
105
+ RbInvoice::Options::parse_command_line(%w{--spreadsheet=foo my-client outfile})
106
+ }.should raise_error SystemExit
107
+ end
108
+
109
+ it "should use the given invoice number" do
110
+ client, start_date, end_date, out_filename, opts = *RbInvoice::Options::parse_command_line(%w{--spreadsheet=foo --invoice-number=1041 my-client outfile})
111
+ opts[:invoice_number].should == 1041
112
+ end
113
+
114
+ it "should use the next invoice number" do
115
+ File.open("#{@tmpdir}/.rbinvoice", 'w') { |f| f.write("last_invoice: 1040") }
116
+ client, start_date, end_date, out_filename, opts = *RbInvoice::Options::parse_command_line(%w{--spreadsheet=foo my-client outfile})
117
+ opts[:invoice_number].should == 1041
118
+ end
119
+
120
+ it "should infer output filename from invoice number and client" do
121
+ client, start_date, end_date, out_filename, opts = *RbInvoice::Options::parse_command_line(%w{--spreadsheet=foo --invoice-number=1041 my-client})
122
+ opts[:out_filename].should == './invoice-1041-my-client.pdf'
123
+ end
124
+
125
+ it "should use the spreadsheet URL from --spreadsheet" do
126
+ client, start_date, end_date, out_filename, opts = *RbInvoice::Options::parse_command_line(%w{--spreadsheet=foo --invoice-number=5 my-client outfile})
127
+ opts[:spreadsheet].should == 'foo'
128
+
129
+ client, start_date, end_date, out_filename, opts = *RbInvoice::Options::parse_command_line(%w{-u foo --invoice-number=5 my-client outfile})
130
+ opts[:spreadsheet].should == 'foo'
131
+ end
132
+
133
+ it "should use the spreadsheet URL from .rbinvoicerc" do
134
+ File.open("#{@tmpdir}/.rbinvoicerc", 'w') { |f| f.write("spreadsheet: http://foo") }
135
+ client, start_date, end_date, out_filename, opts = *RbInvoice::Options::parse_command_line(%w{--invoice-number=1040 my-client outfile})
136
+ opts[:spreadsheet].should == 'http://foo'
137
+ end
138
+
139
+ end
@@ -0,0 +1,55 @@
1
+ \documentclass[12pt]{article}
2
+
3
+ \pagestyle{empty}
4
+
5
+ \begin{document}
6
+
7
+ % \section*{Invoice 12/31/2007}
8
+
9
+ \noindent
10
+ \begin{minipage}[t]{3in}
11
+ Paul Jungwirth \\
12
+ 2520 SW Edgemoor Ave. \\
13
+ Beaverton, OR 97005 \\
14
+ 909 557-0421 \\
15
+ \end{minipage}
16
+ \begin{minipage}[t]{1.9in}
17
+ \begin{flushright}
18
+ {\Large \textbf{Invoice}} \\
19
+ \# {{ invoice_number }} \\
20
+ {{ invoice_date }} \\
21
+ \end{flushright}
22
+ \end{minipage} \\
23
+ {% raw %}
24
+ % \makebox[3.9in][r]{{\Large \textbf{Invoice}} \\ 31 December 2007} \\
25
+ {% endraw %}
26
+
27
+ \noindent {\sc to}: \\
28
+ OK Venue LLC \\
29
+ 116 W 23rd St. \\
30
+ New York, NY 10011 \\
31
+ \smallskip
32
+
33
+ \noindent {\sc for}: \\
34
+ Consulting and programming for the okvenue.com website and related projects. \\
35
+ \linebreak[4]
36
+
37
+ \begin{tabular}{lrr}
38
+ \textbf{Description} & \textbf{Hours} & \textbf{Cost} \\
39
+ \hline
40
+ {% for item in line_items %}
41
+ {{ item.name }} & {{ item.duration }} & \${{ item.price }} \\ {% endfor %}
42
+ \textbf{Total:} & {{ total_duration }} & \${{ total_price }} \\
43
+ \end{tabular}
44
+
45
+ \bigskip
46
+
47
+ \noindent
48
+ Please make all checks payable to Paul Jungwirth. \\
49
+ Payment due net 30 days. \\
50
+
51
+ \begin{center}
52
+ \textbf{Thank you for your business!}
53
+ \end{center}
54
+
55
+ \end{document}
metadata ADDED
@@ -0,0 +1,197 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rbinvoice
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Paul A. Jungwirth
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-08-07 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: trollop
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: roo
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: prawn
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: liquid
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: rspec
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: bundler
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: jeweler
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: simplecov
128
+ requirement: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ! '>='
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ type: :development
135
+ prerelease: false
136
+ version_requirements: !ruby/object:Gem::Requirement
137
+ none: false
138
+ requirements:
139
+ - - ! '>='
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ description: ! ' Reads hours from a Google Spreadsheet and generates a PDF invoice.
143
+
144
+ '
145
+ email: pj@illuminatedcomputing.com
146
+ executables:
147
+ - rbinvoice
148
+ extensions: []
149
+ extra_rdoc_files:
150
+ - LICENSE.txt
151
+ - README.md
152
+ - TODO
153
+ files:
154
+ - Gemfile
155
+ - Gemfile.lock
156
+ - LICENSE.txt
157
+ - NOTES
158
+ - README.md
159
+ - Rakefile
160
+ - VERSION
161
+ - bin/rbinvoice
162
+ - lib/rbinvoice.rb
163
+ - lib/rbinvoice/options.rb
164
+ - lib/rbinvoice/util.rb
165
+ - rbinvoice.gemspec
166
+ - spec/options_spec.rb
167
+ - templates/invoice.tex.liquid
168
+ - TODO
169
+ homepage: http://github.com/pjungwir/rbinvoice
170
+ licenses:
171
+ - MIT
172
+ post_install_message:
173
+ rdoc_options: []
174
+ require_paths:
175
+ - lib
176
+ required_ruby_version: !ruby/object:Gem::Requirement
177
+ none: false
178
+ requirements:
179
+ - - ! '>='
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ segments:
183
+ - 0
184
+ hash: -1042798753782564714
185
+ required_rubygems_version: !ruby/object:Gem::Requirement
186
+ none: false
187
+ requirements:
188
+ - - ! '>='
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ requirements: []
192
+ rubyforge_project:
193
+ rubygems_version: 1.8.24
194
+ signing_key:
195
+ specification_version: 3
196
+ summary: Used to invoice my clients
197
+ test_files: []