billtrap 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,3 @@
1
+ module BillTrap
2
+ VERSION = "0.0.2"
3
+ 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')