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
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
|