billtrap 0.0.2
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/.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')
|