rbinvoice 0.2.0

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.
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: []