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 +18 -0
- data/Gemfile.lock +87 -0
- data/LICENSE.txt +20 -0
- data/NOTES +12 -0
- data/README.md +6 -0
- data/Rakefile +60 -0
- data/TODO +10 -0
- data/VERSION +1 -0
- data/bin/rbinvoice +3 -0
- data/lib/rbinvoice/options.rb +271 -0
- data/lib/rbinvoice/util.rb +29 -0
- data/lib/rbinvoice.rb +157 -0
- data/rbinvoice.gemspec +76 -0
- data/spec/options_spec.rb +139 -0
- data/templates/invoice.tex.liquid +55 -0
- metadata +197 -0
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
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
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.2.0
|
data/bin/rbinvoice
ADDED
@@ -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: []
|