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
data/lib/billtrap/cli.rb
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
module BillTrap
|
3
|
+
module CLI
|
4
|
+
extend Helpers
|
5
|
+
extend self
|
6
|
+
attr_accessor :args
|
7
|
+
|
8
|
+
def invoke
|
9
|
+
require 'cmd/usage'
|
10
|
+
|
11
|
+
case args.first when '-h', '--help', '--usage', '-?', 'help', nil
|
12
|
+
puts BillTrap::CLI.usage
|
13
|
+
exit 0
|
14
|
+
when '-v', '--version'
|
15
|
+
puts "BillTrap version #{BillTrap::VERSION}"
|
16
|
+
exit 0
|
17
|
+
end
|
18
|
+
|
19
|
+
# Grab global options, then stop
|
20
|
+
flags = Trollop::options args do
|
21
|
+
opt :debug
|
22
|
+
stop_on_unknown
|
23
|
+
end
|
24
|
+
|
25
|
+
command = args.shift
|
26
|
+
# Complete command
|
27
|
+
available = commands.select{ |key| key.match(/^#{command}/) }
|
28
|
+
if available.size == 1
|
29
|
+
require "cmd/#{available[0]}"
|
30
|
+
send available[0]
|
31
|
+
elsif available.size > 1
|
32
|
+
warn "Error: Ambiguous command '#{command}'"
|
33
|
+
warn "Matching commands are: #{available.join(", ")}"
|
34
|
+
else
|
35
|
+
warn "Error: Invalid command #{command.inspect}"
|
36
|
+
end
|
37
|
+
rescue StandardError, LoadError => e
|
38
|
+
raise e if flags && flags[:debug]
|
39
|
+
warn e.message
|
40
|
+
end
|
41
|
+
|
42
|
+
def commands
|
43
|
+
BillTrap::CLI.usage.scan(/\* \w+/).map{|s| s.gsub(/\* /, '')}
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def confirm question
|
49
|
+
print "#{question} ? "
|
50
|
+
$stdin.gets =~ /\Aye?s?\Z/i
|
51
|
+
rescue Interrupt
|
52
|
+
# Avoid ugly trace
|
53
|
+
warn "\nCaught Interrupt. Exiting"
|
54
|
+
exit 1
|
55
|
+
end
|
56
|
+
|
57
|
+
def ask_value name, multiline=false
|
58
|
+
print "#{name}: "
|
59
|
+
if multiline
|
60
|
+
puts "(Multiline input, type Ctrl-D or insert END and return to exit)"
|
61
|
+
val = ($stdin.gets("END") || '').chomp("END")
|
62
|
+
puts
|
63
|
+
else
|
64
|
+
val = ($stdin.gets || '').chomp
|
65
|
+
end
|
66
|
+
return val
|
67
|
+
rescue Interrupt
|
68
|
+
# Avoid ugly trace
|
69
|
+
warn "\nCaught Interrupt. Exiting"
|
70
|
+
exit 1
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def client
|
4
|
+
opts = Trollop::options(args) do
|
5
|
+
opt :add, "Add a new ID, reading from STDIN", :short => '-a'
|
6
|
+
opt :delete, "Delete the client by ID", :type => Integer, :short => '-d'
|
7
|
+
end
|
8
|
+
|
9
|
+
if opts[:add]
|
10
|
+
firstname = ask_value "First name"
|
11
|
+
surname = ask_value "Surname"
|
12
|
+
company = ask_value "Company"
|
13
|
+
address = ask_value "Address", true
|
14
|
+
mail = ask_value "Mail"
|
15
|
+
rate = ask_value "Hourly rate"
|
16
|
+
currency = ask_value "Use non-standard Currency? [Leave empty for #{BillTrap::Config['currency']}]"
|
17
|
+
|
18
|
+
currency = currency.empty? ? BillTrap::Config['currency'] : currency
|
19
|
+
puts "'#{currency}'"
|
20
|
+
|
21
|
+
client = Client.create(
|
22
|
+
:firstname => firstname,
|
23
|
+
:surname => surname,
|
24
|
+
:company => company,
|
25
|
+
:address => address,
|
26
|
+
:mail => mail,
|
27
|
+
:rate => Money.parse(rate, currency).cents,
|
28
|
+
:currency => currency
|
29
|
+
)
|
30
|
+
puts "Client #{firstname} #{surname} was created with id #{client.id}"
|
31
|
+
elsif id = opts[:delete]
|
32
|
+
if e = Client.get(id)
|
33
|
+
if confirm "Are you sure you want to delete Client #{e.name} (##{e.id})"
|
34
|
+
begin
|
35
|
+
e.destroy
|
36
|
+
puts "Client has been removed."
|
37
|
+
rescue Sequel::ForeignKeyConstraintViolation
|
38
|
+
warn 'Error: Client is still in use. Refusing to delete client'
|
39
|
+
end
|
40
|
+
else
|
41
|
+
puts "Client has NOT been removed."
|
42
|
+
end
|
43
|
+
else
|
44
|
+
warn "Can't find Client with id '#{id}'"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def entry
|
4
|
+
opts = Trollop::options args do
|
5
|
+
opt :add, "Manually add entry to current invoice", :short => '-a'
|
6
|
+
opt :delete, "Delete entry from current invoice by ID", :type => :int, :short => '-d'
|
7
|
+
end
|
8
|
+
current = Invoice.current
|
9
|
+
if opts[:add]
|
10
|
+
title = ask_value 'Entry title'
|
11
|
+
date = ask_value 'Entry date (YYYY-MM-DD)'
|
12
|
+
unit = ask_value "Displayed unit (Defaults to 'h' for hours)" || 'h'
|
13
|
+
count = ask_value "Quantity (Numeric)"
|
14
|
+
price = ask_value "Price in #{current.currency} per unit (Numeric)"
|
15
|
+
notes = ask_value 'Optional Notes', true
|
16
|
+
|
17
|
+
e = InvoiceEntry.create(
|
18
|
+
:invoice_id => current.id,
|
19
|
+
:title => title,
|
20
|
+
:date => Date.parse(date),
|
21
|
+
:unit => unit,
|
22
|
+
:count => count,
|
23
|
+
:notes => notes,
|
24
|
+
:cents => Money.parse(price).cents
|
25
|
+
)
|
26
|
+
|
27
|
+
puts "Added entry (##{e.id}) to current invoice (ID #{current.id})"
|
28
|
+
elsif name = opts[:name]
|
29
|
+
Invoice.current.update(:name => name)
|
30
|
+
puts "Set current invoice (##{Invoice.current.id}) name to: #{name}"
|
31
|
+
elsif id = opts[:delete]
|
32
|
+
if e = InvoiceEntry[id]
|
33
|
+
if confirm "Are you sure you want to delete InvoiceEntry ##{e.id}"
|
34
|
+
e.destroy
|
35
|
+
puts "Entry has been removed."
|
36
|
+
else
|
37
|
+
puts "Entry has NOT been removed."
|
38
|
+
end
|
39
|
+
else
|
40
|
+
warn "Can't find entry with id '#{id}'"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def export
|
4
|
+
opts = Trollop::options args do
|
5
|
+
opt :adapter, "Set adapter", :type => :string, :short => '-a'
|
6
|
+
end
|
7
|
+
adapter = opts[:adapter] || 'ooffice'
|
8
|
+
begin
|
9
|
+
# Replace invoice number placeholders
|
10
|
+
arg = {
|
11
|
+
# Unique, auto-incremented invoice id (from database)
|
12
|
+
:invoice_id => Invoice.current.id,
|
13
|
+
# Unique, auto-incremented client id
|
14
|
+
:client_id => Invoice.current.client_id
|
15
|
+
}
|
16
|
+
|
17
|
+
# Replace above parameters, then strfime parameters
|
18
|
+
invoice_number = Invoice.current.created.strftime(Config['invoice_number_format'].gsub(/%\{(.*?)\}/) { arg[$1.to_sym] })
|
19
|
+
|
20
|
+
attributes = {
|
21
|
+
:invoice => Invoice.current,
|
22
|
+
:invoice_number => invoice_number,
|
23
|
+
}
|
24
|
+
|
25
|
+
BillTrap::Adapters.load_adapter(adapter).new(attributes).generate
|
26
|
+
rescue LoadError
|
27
|
+
warn "Couldn't load adapter named #{adapter}.rb"
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def import
|
4
|
+
opts = Trollop::options args do
|
5
|
+
opt :clear, "Clear entries before import", :short => '-c'
|
6
|
+
opt :entry, "Import entries by ID", :type => :strings, :multi => true, :short => '-e'
|
7
|
+
opt :round, "Round imported entries", :short => '-r'
|
8
|
+
opt :sheet, "Import sheet by name", :type => :string, :short => '-s'
|
9
|
+
end
|
10
|
+
|
11
|
+
# Clear entries if --clear given
|
12
|
+
if opts[:clear]
|
13
|
+
InvoiceEntry.where(:invoice_id => Invoice.current.id).destroy
|
14
|
+
end
|
15
|
+
|
16
|
+
entries =
|
17
|
+
if opts[:sheet]
|
18
|
+
Entry.filter(:sheet => opts[:sheet]).all
|
19
|
+
elsif opts[:entry_given]
|
20
|
+
Entry.where(:id => opts[:entry].first).all
|
21
|
+
else
|
22
|
+
[]
|
23
|
+
end
|
24
|
+
|
25
|
+
unless entries.length > 0
|
26
|
+
warn "No matching entries found."
|
27
|
+
return
|
28
|
+
end
|
29
|
+
|
30
|
+
entries.each do |e|
|
31
|
+
Entry.round = opts[:round]
|
32
|
+
# Ignore entry if (rounded) is empty
|
33
|
+
next if e.duration == 0
|
34
|
+
imported = InvoiceEntry.create(
|
35
|
+
:invoice_id => Invoice.current.id,
|
36
|
+
:title => e.sheet,
|
37
|
+
:date => e.start.to_date,
|
38
|
+
:unit => 'h',
|
39
|
+
:count => (e.duration.to_f / 3600).round(2),
|
40
|
+
:notes => e.note,
|
41
|
+
:cents => Invoice.current.rate.cents
|
42
|
+
)
|
43
|
+
puts "Imported #{imported.count} hours from sheet #{e.sheet} as entry ##{imported.id}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def in
|
4
|
+
key = args.shift || raise('Error: No ID/Name given')
|
5
|
+
invoice = Invoice.get key
|
6
|
+
|
7
|
+
if invoice
|
8
|
+
puts "Activating invoice ##{invoice.id}"
|
9
|
+
# set current id
|
10
|
+
Invoice.current = invoice.id
|
11
|
+
else
|
12
|
+
puts "No Invoice found for input '#{key}'"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def new
|
4
|
+
opts = Trollop::options args do
|
5
|
+
opt :client, "Optional Client ID", :type => :string, :short => '-c'
|
6
|
+
opt :date, "Optional invoice date", :type => :string, :short => '-d'
|
7
|
+
opt :name, "Optional invoice name", :type => :string, :short => '-n'
|
8
|
+
end
|
9
|
+
|
10
|
+
date =
|
11
|
+
if opts[:date]
|
12
|
+
Chronic.parse(opts[:date])
|
13
|
+
else
|
14
|
+
Date.today
|
15
|
+
end
|
16
|
+
|
17
|
+
|
18
|
+
invoice = Invoice.create(
|
19
|
+
:name => opts[:name],
|
20
|
+
:created => date,
|
21
|
+
:client => Client.get(opts[:client])
|
22
|
+
)
|
23
|
+
# Make active
|
24
|
+
Invoice.current = invoice.id
|
25
|
+
puts "Created invoice ##{invoice.id}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def payment
|
4
|
+
opts = Trollop::options args do
|
5
|
+
opt :add, "Add payment to current invoice", :type => :strings, :multi => true, :short => '-a'
|
6
|
+
opt :delete, "Delete payment by ID from current invoice", :type => :int, :short => '-d'
|
7
|
+
end
|
8
|
+
if opts[:add_given] && opts[:add][0].length > 1
|
9
|
+
# If the invoice has no total
|
10
|
+
if Invoice.current.total.cents == 0
|
11
|
+
warn "Can't add payment. Invoice ##{Invoice.current.id} has no total"
|
12
|
+
return
|
13
|
+
end
|
14
|
+
|
15
|
+
# Test if payment would add more than the remaining amount
|
16
|
+
payment = Money.parse(opts[:add][0].shift, Invoice.current.currency)
|
17
|
+
if (Invoice.current.received_amount + payment > Invoice.current.total)
|
18
|
+
warn 'With this payment, the received amount surpasses its total.'
|
19
|
+
cropped = Invoice.current.total - Invoice.current.received_amount
|
20
|
+
if ask_value "Do you want to add the remaining payment of #{format_money(cropped)}"
|
21
|
+
payment = cropped
|
22
|
+
else
|
23
|
+
puts "Payment has NOT been added."
|
24
|
+
return
|
25
|
+
end
|
26
|
+
end
|
27
|
+
Invoice.current.add_payment(:cents => payment.cents, :note => opts[:add][0].shift)
|
28
|
+
puts "Added #{format_money(payment)} to current invoice"
|
29
|
+
elsif opts[:delete]
|
30
|
+
if e = Payment[opts[:delete]]
|
31
|
+
if confirm "Are you sure you want to delete Payment ##{e.id}"
|
32
|
+
e.destroy
|
33
|
+
puts "Payment has been removed."
|
34
|
+
else
|
35
|
+
puts "Payment has NOT been removed."
|
36
|
+
end
|
37
|
+
else
|
38
|
+
warn "Error: No Payment found for id ##{opts[:delete]}"
|
39
|
+
end
|
40
|
+
else
|
41
|
+
warn "Error: Invalid command"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def set
|
4
|
+
# Grab subcommand
|
5
|
+
k = args.shift
|
6
|
+
case
|
7
|
+
when k == 'client'
|
8
|
+
id = args.shift
|
9
|
+
if e = Client.get(id)
|
10
|
+
Invoice.current.update(:client_id => e.id)
|
11
|
+
puts "SET client to #{e.name} (##{e.id})"
|
12
|
+
else
|
13
|
+
warn "Error: Can't find Client with id '#{id}'"
|
14
|
+
end
|
15
|
+
when k == 'date'
|
16
|
+
if d = args.shift
|
17
|
+
new_date = Date.parse d
|
18
|
+
else
|
19
|
+
new_date = Date.today
|
20
|
+
end
|
21
|
+
Invoice.current.update(:created => new_date)
|
22
|
+
puts "SET created date to #{format_date(new_date)}"
|
23
|
+
when k == 'name'
|
24
|
+
if n = args.shift
|
25
|
+
Invoice.current.update(:name => n)
|
26
|
+
puts "SET name to '#{n}'"
|
27
|
+
else
|
28
|
+
warn "Error: Missing required attributed for token 'name'"
|
29
|
+
end
|
30
|
+
when k == 'sent'
|
31
|
+
if d = args.shift
|
32
|
+
Invoice.current.update(:sent => Date.parse(d))
|
33
|
+
puts "SET invoice sent date to #{d}"
|
34
|
+
else
|
35
|
+
Invoice.current.update(:sent => nil)
|
36
|
+
puts "UNSET invoice sent date"
|
37
|
+
end
|
38
|
+
when k.respond_to?(:to_s)
|
39
|
+
Invoice.current.set_attr(k.to_str, args.shift)
|
40
|
+
puts "Setting attribute #{k}"
|
41
|
+
else
|
42
|
+
warn "Error: Missing / unrecognized TOKEN #{k}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module BillTrap
|
2
|
+
module CLI
|
3
|
+
def show
|
4
|
+
opts = Trollop::options args do
|
5
|
+
opt :completed, "Show only completed (i.e., sent and paid) invoices", :short => '-c'
|
6
|
+
opt :detail, "Show details (including entries) of a particular invoice", :type => :string, :short => '-d'
|
7
|
+
end
|
8
|
+
if opts[:detail]
|
9
|
+
# Display details of invoice with id/name from args
|
10
|
+
if invoice = Invoice.get(opts[:detail])
|
11
|
+
|
12
|
+
puts "%-12s%s" % ["Invoice: ", "#{invoice.name || 'unnamed'} (##{invoice.id})"]
|
13
|
+
puts "%-12s%s" % ["Created on: ", format_date(invoice.created)]
|
14
|
+
if invoice.sent
|
15
|
+
puts "%-12s%s" % ["Sent on: ", format_date(invoice.sent)]
|
16
|
+
end
|
17
|
+
|
18
|
+
puts '-' * 22
|
19
|
+
if invoice.invoice_entries.size > 0
|
20
|
+
puts 'Invoice entries'
|
21
|
+
# Determine length of entry titles
|
22
|
+
width = invoice.invoice_entries.sort_by{|inv| inv.title.to_s.length }.last.title.to_s.length + 4
|
23
|
+
width = 12 if width < 12
|
24
|
+
puts " %-#{width}s%-12s%-12s%-12s%s" % ["Title", "Date", "Quantity", "Price", "Notes"]
|
25
|
+
invoice.invoice_entries.each do |e|
|
26
|
+
puts " %-#{width}s%-12s%-12s%-12s%s" % [
|
27
|
+
e.title,
|
28
|
+
e.date,
|
29
|
+
e.typed_amount,
|
30
|
+
format_money(e.total),
|
31
|
+
e.notes
|
32
|
+
]
|
33
|
+
end
|
34
|
+
else
|
35
|
+
puts 'No InvoiceEntries'
|
36
|
+
end
|
37
|
+
|
38
|
+
if invoice.payments.size > 0
|
39
|
+
puts '-' * 22
|
40
|
+
puts 'Received payments'
|
41
|
+
puts " %12s %s" % ["Payment", "Notes"]
|
42
|
+
invoice.payments.each do |e|
|
43
|
+
puts " %12s %s" % [
|
44
|
+
format_money(e.amount),
|
45
|
+
e.note
|
46
|
+
]
|
47
|
+
end
|
48
|
+
else
|
49
|
+
end
|
50
|
+
|
51
|
+
else
|
52
|
+
puts "No Invoice found for input '#{detail_id}'"
|
53
|
+
end
|
54
|
+
elsif opts[:completed]
|
55
|
+
puts 'Showing only completed invoices'
|
56
|
+
print_invoices Invoice.completed
|
57
|
+
else
|
58
|
+
puts 'Showing open invoices'
|
59
|
+
print_invoices Invoice.open
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
def print_invoices invoices
|
65
|
+
if invoices.empty?
|
66
|
+
warn 'No matching invoices found'
|
67
|
+
return
|
68
|
+
end
|
69
|
+
|
70
|
+
# Determine length of invoice names
|
71
|
+
width = invoices.sort_by{|inv| inv.name.to_s.length }.last.name.to_s.length + 4
|
72
|
+
width = 12 if width < 12
|
73
|
+
puts " %-6s%-#{width}s%-24s%-16s%s" % ["ID", "Name", "Client", "Created", "Payments / Total"]
|
74
|
+
invoices.each do |i|
|
75
|
+
active = (Invoice.current.id == i.id) ? '>>' : ''
|
76
|
+
puts " %-6s%-#{width}s%-24s%-16s%s" % [
|
77
|
+
"#{active}#{i.id}",
|
78
|
+
i.name || ' - ',
|
79
|
+
i.client ? i.client.name : ' - ',
|
80
|
+
i.created,
|
81
|
+
"#{format_money(i.received_amount)} / #{format_money(i.total)}"
|
82
|
+
]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
end
|