billtrap 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +21 -0
- data/Gemfile +2 -0
- data/LICENSE +22 -0
- data/README.md +218 -0
- data/Rakefile +21 -0
- data/billtrap.gemspec +35 -0
- data/bin/bt +12 -0
- data/bin/dev_b +6 -0
- data/lib/billtrap.rb +56 -0
- data/lib/billtrap/adapters.rb +12 -0
- data/lib/billtrap/adapters/ooffice.rb +27 -0
- data/lib/billtrap/cli.rb +73 -0
- data/lib/billtrap/cmd/client.rb +50 -0
- data/lib/billtrap/cmd/configure.rb +8 -0
- data/lib/billtrap/cmd/entry.rb +45 -0
- data/lib/billtrap/cmd/export.rb +32 -0
- data/lib/billtrap/cmd/import.rb +47 -0
- data/lib/billtrap/cmd/in.rb +16 -0
- data/lib/billtrap/cmd/new.rb +28 -0
- data/lib/billtrap/cmd/payment.rb +45 -0
- data/lib/billtrap/cmd/set.rb +46 -0
- data/lib/billtrap/cmd/show.rb +87 -0
- data/lib/billtrap/cmd/usage.rb +80 -0
- data/lib/billtrap/config.rb +68 -0
- data/lib/billtrap/helpers.rb +17 -0
- data/lib/billtrap/models.rb +239 -0
- data/lib/billtrap/version.rb +3 -0
- data/lib/serenity/LICENSE +22 -0
- data/lib/serenity/serenity.rb +9 -0
- data/lib/serenity/serenity/debug.rb +19 -0
- data/lib/serenity/serenity/escape_xml.rb +18 -0
- data/lib/serenity/serenity/generator.rb +18 -0
- data/lib/serenity/serenity/line.rb +68 -0
- data/lib/serenity/serenity/node_type.rb +7 -0
- data/lib/serenity/serenity/odteruby.rb +90 -0
- data/lib/serenity/serenity/template.rb +31 -0
- data/lib/serenity/serenity/xml_reader.rb +31 -0
- data/migrations/001_base.rb +48 -0
- data/spec/billtrap_spec.rb +283 -0
- metadata +285 -0
@@ -0,0 +1,80 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def usage
|
4
|
+
<<-EOF
|
5
|
+
|
6
|
+
Billtrap - Manage invoices, import time slices from Timetrap
|
7
|
+
|
8
|
+
Usage: bt COMMAND [OPTIONS] [ARGS...]
|
9
|
+
|
10
|
+
COMMAND can be abbreviated. For example `bt edit --delete 1` and `bt e -d 1` are equivalent.
|
11
|
+
|
12
|
+
COMMAND is one of:
|
13
|
+
|
14
|
+
* configure - Write out a YAML config file to HOME/.billtrap.yml.
|
15
|
+
usage: bt configure
|
16
|
+
|
17
|
+
* client - Manage clients (adding, deleting and connecting to current invoice)
|
18
|
+
usage: bt client [--add] [--delete [ID]] [--set [ID]]
|
19
|
+
-a, --add Manually add a client, reads from STDIN
|
20
|
+
-d, --delete Delete a client. If no ID is given, prints all clients.
|
21
|
+
Note: Clients cannot be deleted if a non-archived invoiced is linked to it.
|
22
|
+
|
23
|
+
* entry - Edit the active invoice, adding or deleting invoice entries manually
|
24
|
+
usage: bt edit [--add] [--delete [ID]]
|
25
|
+
-a, --add Manually add an invoice entry (timeslice or product), reads from STDIN
|
26
|
+
-d, --delete Delete an invoice entry. If no ID is given, prints all entries and asks for an ID.
|
27
|
+
|
28
|
+
* export - Export active invoice, using the default adapter (Serenity)
|
29
|
+
-a, --adapter Override the default adapter
|
30
|
+
|
31
|
+
* in - Switch to another invoice, making it active for edits
|
32
|
+
usage: bt in [ID | NAME]
|
33
|
+
|
34
|
+
* import - Import data from Timetrap. Sets invoice title to the sheet name and description to notes
|
35
|
+
usage: bt import [--clear] [--sheet NAME] [--entry ID [ID ..]]
|
36
|
+
-c, --clear Clears ALL invoice entries before import
|
37
|
+
-e, --entry Import the given entries.
|
38
|
+
-s, --sheet Import all entries from the given sheet.
|
39
|
+
|
40
|
+
* new - Create a new invoice, activating it for edits
|
41
|
+
usage: bt new [--name NAME] [--date DATE] [--client ID | NAME]
|
42
|
+
-c, --client Tie the invoice to a client
|
43
|
+
-d, --date Set to override invoice date (defaults to today)
|
44
|
+
-n, --name Set invoice reference name
|
45
|
+
|
46
|
+
* payment - add, remove payments to current id
|
47
|
+
usage: bt payment [--add AMOUNT ['NOTES']] [--delete ID]
|
48
|
+
-a, --add Add a payment to current invoice
|
49
|
+
-d, --delete Delete a payment by ID from current invoice
|
50
|
+
|
51
|
+
* set - Set variables on the current invoice
|
52
|
+
usage: bt set TOKEN [VALUE]
|
53
|
+
Where TOKEN is one of
|
54
|
+
client Set a client by id or surname
|
55
|
+
date Set the `created on` date (YYYY-MM-DD), leave empty for today
|
56
|
+
name Set name of current invoice
|
57
|
+
sent Set the `sent on` date (YYYY-MM-DD), leave empty for today
|
58
|
+
other Add { 'other' => VALUE } to the invoice's custom attributes.
|
59
|
+
Use this to set attributes for populating templates
|
60
|
+
|
61
|
+
* show - Display a list invoices. Shows pending invoices by default (open or unpaid)
|
62
|
+
usage: bt show [--details ID | NAME] [--completed]
|
63
|
+
-d, --detail Show details (including entries) of a particular invoice
|
64
|
+
-c, --completed Show only completed (i.e., sent and paid) invoices
|
65
|
+
|
66
|
+
GLOBAL OPTIONS
|
67
|
+
Use global options by prepending them before any command.
|
68
|
+
--debug Display stack traces for errors.
|
69
|
+
usage: bt --debug COMMAND [ARGS]
|
70
|
+
|
71
|
+
OTHER OPTIONS
|
72
|
+
-h, --help Display this help.
|
73
|
+
|
74
|
+
EXAMPLES
|
75
|
+
|
76
|
+
Please submit bugs and feature requests to http://github.com/oliverguenther/billtrap/issues
|
77
|
+
EOF
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module Config
|
3
|
+
extend self
|
4
|
+
|
5
|
+
CONFIG_PATH = File.join(BILLTRAP_HOME, 'billtrap.yml')
|
6
|
+
|
7
|
+
# Application defaults.
|
8
|
+
#
|
9
|
+
# These are written to BILLTRAP_CONFIG_PATH or <HOME>/.billtrap/billtrap.yml by executing
|
10
|
+
# <code>
|
11
|
+
# billtrap configure
|
12
|
+
# </code>
|
13
|
+
def defaults
|
14
|
+
{
|
15
|
+
# Database identifier, defaults to 'sqlite://<BILLTRAP_HOME>/.billtrap.db'
|
16
|
+
'database' => "sqlite://#{BILLTRAP_HOME}/billtrap.db",
|
17
|
+
# Timetrap database, used to import Entries
|
18
|
+
'timetrap_database' => "sqlite://#{ENV['HOME']}/.timetrap.db",
|
19
|
+
# We'll also need the round specifier
|
20
|
+
'round_in_seconds' => 900,
|
21
|
+
# Path to invoice archive
|
22
|
+
'billtrap_archive' => "#{ENV['HOME']}/Documents/billtrap/invoices",
|
23
|
+
# Currency to use (see RubyMoney for codes)
|
24
|
+
'currency' => 'USD',
|
25
|
+
# Default rate in the above currency
|
26
|
+
'default_rate' => '25.00',
|
27
|
+
# Invoice numbering scheme
|
28
|
+
# TODO possible values
|
29
|
+
'invoice_number_format' => "%Y%m%d_%{invoice_id}",
|
30
|
+
# Money formatter
|
31
|
+
# See http://rubydoc.info/gems/money/Money/Formatting for options
|
32
|
+
'currency_format' => { :with_currency => true , :symbol => false},
|
33
|
+
# Date output format
|
34
|
+
'date_format' => '%Y-%m-%d',
|
35
|
+
# Due date in days
|
36
|
+
'due_date' => 30,
|
37
|
+
# Default invoice adapter to use when none is specified
|
38
|
+
'default_formatter' => 'serenity',
|
39
|
+
# Serenity adapter: Path to invoice template
|
40
|
+
'serenity_template' => "#{BILLTRAP_HOME}/.billtrap_template.odt",
|
41
|
+
}
|
42
|
+
end
|
43
|
+
|
44
|
+
def [](key)
|
45
|
+
overrides = File.exist?(CONFIG_PATH) ? YAML.load(erb_render(File.read(CONFIG_PATH))) : {}
|
46
|
+
defaults.merge(overrides)[key]
|
47
|
+
rescue => e
|
48
|
+
warn "invalid config file"
|
49
|
+
warn e.message
|
50
|
+
defaults[key]
|
51
|
+
end
|
52
|
+
|
53
|
+
def erb_render(content)
|
54
|
+
ERB.new(content).result
|
55
|
+
end
|
56
|
+
|
57
|
+
def configure!
|
58
|
+
configs = if File.exist?(CONFIG_PATH)
|
59
|
+
defaults.merge(YAML.load_file(CONFIG_PATH))
|
60
|
+
else
|
61
|
+
defaults
|
62
|
+
end
|
63
|
+
File.open(CONFIG_PATH, 'w') do |fh|
|
64
|
+
fh.puts(configs.to_yaml)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module Helpers
|
3
|
+
|
4
|
+
def is_i? val
|
5
|
+
!!(val.is_a? Integer or val =~ /^\d+$/)
|
6
|
+
end
|
7
|
+
|
8
|
+
def format_date d, format_str=BillTrap::Config['date_format']
|
9
|
+
d.strftime(format_str)
|
10
|
+
end
|
11
|
+
|
12
|
+
def format_money m, opts=BillTrap::Config['currency_format']
|
13
|
+
m.format(opts)
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,239 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
# uses json for serialization of
|
3
|
+
# custom invoice flags
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
# encoding: UTF-8
|
7
|
+
module BillTrap
|
8
|
+
class Client < Sequel::Model
|
9
|
+
extend Helpers
|
10
|
+
one_to_many :invoices
|
11
|
+
|
12
|
+
# Retrieve client
|
13
|
+
# If numeric, assumes it's an ID
|
14
|
+
# If string, returns first match c == surname
|
15
|
+
# Returns nil otherwise / if no match was found
|
16
|
+
def self.get c
|
17
|
+
if is_i? c
|
18
|
+
Client[c]
|
19
|
+
elsif c.is_a? String
|
20
|
+
Client.find(:surname => c)
|
21
|
+
else
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
def name
|
28
|
+
"#{firstname} #{surname}"
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
class Invoice < Sequel::Model
|
34
|
+
extend Helpers
|
35
|
+
|
36
|
+
# Associates to a client
|
37
|
+
many_to_one :client
|
38
|
+
|
39
|
+
# Associates to many InvoiceEntries
|
40
|
+
one_to_many :invoice_entries
|
41
|
+
|
42
|
+
# Associates to many Payments
|
43
|
+
one_to_many :payments
|
44
|
+
|
45
|
+
# Serialize custom flags
|
46
|
+
plugin :serialization, :json, :attributes
|
47
|
+
|
48
|
+
def self.open
|
49
|
+
all.select { |i| !i.paid? }
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.completed
|
53
|
+
exclude(:sent => nil).all.select { |i| i.paid? }
|
54
|
+
end
|
55
|
+
|
56
|
+
# Retrieve invoice
|
57
|
+
# If numeric, assumes it's an ID
|
58
|
+
# If string, returns first match inv == name
|
59
|
+
# Returns nil otherwise / if no match was found
|
60
|
+
def self.get inv
|
61
|
+
if is_i? inv
|
62
|
+
Invoice[inv]
|
63
|
+
elsif inv.is_a? String
|
64
|
+
Invoice.find(:name => inv)
|
65
|
+
else
|
66
|
+
nil
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.current= id
|
71
|
+
last = Meta.find_or_create(:key => 'current_invoice')
|
72
|
+
last.value = id
|
73
|
+
last.save
|
74
|
+
end
|
75
|
+
|
76
|
+
def self.current
|
77
|
+
last = Meta.find(:key => 'current_invoice')
|
78
|
+
Invoice[last.value]
|
79
|
+
end
|
80
|
+
|
81
|
+
def currency
|
82
|
+
if client_id
|
83
|
+
client.currency
|
84
|
+
else
|
85
|
+
BillTrap::Config['currency']
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def set_attr k,v
|
90
|
+
attributes[k] = v
|
91
|
+
save
|
92
|
+
end
|
93
|
+
|
94
|
+
def total
|
95
|
+
sum = Money.new(0, currency)
|
96
|
+
entries = InvoiceEntry.filter(:invoice_id=>id)
|
97
|
+
entries.each do |entry|
|
98
|
+
sum += entry.total
|
99
|
+
end
|
100
|
+
return sum
|
101
|
+
end
|
102
|
+
|
103
|
+
def paid?
|
104
|
+
if sent.nil?
|
105
|
+
return false
|
106
|
+
end
|
107
|
+
# TODO == vs. >= ?
|
108
|
+
puts "received = #{received_amount}, total = #{total}"
|
109
|
+
return received_amount == total
|
110
|
+
end
|
111
|
+
|
112
|
+
def rate
|
113
|
+
rate = if client.nil?
|
114
|
+
BillTrap::Config['default_rate']
|
115
|
+
else
|
116
|
+
client.rate
|
117
|
+
end
|
118
|
+
Money.parse(rate, currency)
|
119
|
+
end
|
120
|
+
|
121
|
+
|
122
|
+
def received_amount
|
123
|
+
cents = Payment.where(:invoice_id => id).sum(:cents)
|
124
|
+
Money.new(cents, currency)
|
125
|
+
end
|
126
|
+
|
127
|
+
def overdue?
|
128
|
+
due_date > Date.today
|
129
|
+
end
|
130
|
+
|
131
|
+
def due_date
|
132
|
+
created + BillTrap::Config['due_date']
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
136
|
+
|
137
|
+
class InvoiceEntry < Sequel::Model
|
138
|
+
|
139
|
+
# Associates with one invoice
|
140
|
+
many_to_one :invoice
|
141
|
+
|
142
|
+
def typed_amount
|
143
|
+
return "#{count}#{unit}"
|
144
|
+
end
|
145
|
+
|
146
|
+
def total
|
147
|
+
Money.new(cents, invoice.currency) * count
|
148
|
+
end
|
149
|
+
|
150
|
+
end
|
151
|
+
|
152
|
+
class Payment < Sequel::Model
|
153
|
+
|
154
|
+
# Associates with one invoice
|
155
|
+
many_to_one :invoice
|
156
|
+
|
157
|
+
def amount
|
158
|
+
Money.new(cents, invoice.currency)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
|
163
|
+
class Entry < Sequel::Model(TT_DB)
|
164
|
+
class << self
|
165
|
+
attr_accessor :round
|
166
|
+
end
|
167
|
+
|
168
|
+
def round?
|
169
|
+
!!self.class.round
|
170
|
+
end
|
171
|
+
|
172
|
+
def date
|
173
|
+
start.to_date
|
174
|
+
end
|
175
|
+
|
176
|
+
def start= time
|
177
|
+
self[:start]= Timer.process_time(time)
|
178
|
+
end
|
179
|
+
|
180
|
+
def end= time
|
181
|
+
self[:end]= Timer.process_time(time)
|
182
|
+
end
|
183
|
+
|
184
|
+
def start
|
185
|
+
round? ? rounded_start : self[:start]
|
186
|
+
end
|
187
|
+
|
188
|
+
def end
|
189
|
+
round? ? rounded_end : self[:end]
|
190
|
+
end
|
191
|
+
|
192
|
+
def sheet
|
193
|
+
self[:sheet].to_s
|
194
|
+
end
|
195
|
+
|
196
|
+
def duration
|
197
|
+
@duration ||= self.end_or_now.to_i - self.start.to_i
|
198
|
+
end
|
199
|
+
|
200
|
+
def duration
|
201
|
+
@rounded_duration ||= self.end_or_now.to_i - self.start.to_i
|
202
|
+
end
|
203
|
+
|
204
|
+
|
205
|
+
def end_or_now
|
206
|
+
self.end || (round? ? round(Time.now) : Time.now)
|
207
|
+
end
|
208
|
+
|
209
|
+
def rounded_start
|
210
|
+
round(self[:start])
|
211
|
+
end
|
212
|
+
|
213
|
+
def rounded_end
|
214
|
+
round(self[:end])
|
215
|
+
end
|
216
|
+
|
217
|
+
def round time, roundsecs=BillTrap::Config['round_in_seconds']
|
218
|
+
return nil unless time
|
219
|
+
Time.at(
|
220
|
+
if (r = time.to_i % roundsecs) < 450
|
221
|
+
time.to_i - r
|
222
|
+
else
|
223
|
+
time.to_i + (roundsecs - r)
|
224
|
+
end
|
225
|
+
)
|
226
|
+
end
|
227
|
+
|
228
|
+
def self.sheets
|
229
|
+
map{|e|e.sheet}.uniq.sort
|
230
|
+
end
|
231
|
+
|
232
|
+
end
|
233
|
+
|
234
|
+
class Meta < Sequel::Model(:meta)
|
235
|
+
def value
|
236
|
+
self[:value].to_s
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2010 Tomas Kramar
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person
|
4
|
+
obtaining a copy of this software and associated documentation
|
5
|
+
files (the "Software"), to deal in the Software without
|
6
|
+
restriction, including without limitation the rights to use,
|
7
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
8
|
+
copies of the Software, and to permit persons to whom the
|
9
|
+
Software is furnished to do so, subject to the following
|
10
|
+
conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be
|
13
|
+
included in all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
16
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
17
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
18
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
19
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
20
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
21
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
22
|
+
OTHER DEALINGS IN THE SOFTWARE.
|
@@ -0,0 +1,9 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require File.join(File.dirname(__FILE__), 'serenity' , 'line')
|
3
|
+
require File.join(File.dirname(__FILE__), 'serenity' , 'debug')
|
4
|
+
require File.join(File.dirname(__FILE__), 'serenity' , 'escape_xml')
|
5
|
+
require File.join(File.dirname(__FILE__), 'serenity' , 'node_type')
|
6
|
+
require File.join(File.dirname(__FILE__), 'serenity' , 'odteruby')
|
7
|
+
require File.join(File.dirname(__FILE__), 'serenity' , 'template')
|
8
|
+
require File.join(File.dirname(__FILE__), 'serenity' , 'xml_reader')
|
9
|
+
require File.join(File.dirname(__FILE__), 'serenity' , 'generator')
|