Make invoices task oriented; implemented correct invoicing.
* Split a task in two when billing. The task tied to the invoice contains all time entries to be billed. * For a fixed cost task all time entries are billed automatically, no selection is allowed. * Updated the models with helper methods (billing, period, summaries). * Prepare for improved templating. * Improved invoice numbering. * Improved support for fixed cost tasks.
This commit is contained in:
parent
0f59b9ceae
commit
69d6424462
183
stoptime.rb
183
stoptime.rb
|
@ -50,12 +50,18 @@ module StopTime::Models
|
||||||
|
|
||||||
class Customer < Base
|
class Customer < Base
|
||||||
has_many :tasks
|
has_many :tasks
|
||||||
|
has_many :invoices
|
||||||
has_many :time_entries, :through => :tasks
|
has_many :time_entries, :through => :tasks
|
||||||
|
|
||||||
|
def unbilled_tasks
|
||||||
|
tasks.all(:conditions => ["invoice_id IS NULL"])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class Task < Base
|
class Task < Base
|
||||||
has_many :time_entries
|
has_many :time_entries
|
||||||
belongs_to :customer
|
belongs_to :customer
|
||||||
|
belongs_to :invoice
|
||||||
|
|
||||||
def fixed_cost?
|
def fixed_cost?
|
||||||
not self.fixed_cost.blank?
|
not self.fixed_cost.blank?
|
||||||
|
@ -64,11 +70,36 @@ module StopTime::Models
|
||||||
def task_type
|
def task_type
|
||||||
fixed_cost? ? "fixed_cost" : "hourly_rate"
|
fixed_cost? ? "fixed_cost" : "hourly_rate"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def billable_time_entries
|
||||||
|
time_entries.all(:conditions => ["bill = 't'"], :order => "start ASC")
|
||||||
|
end
|
||||||
|
|
||||||
|
def bill_period
|
||||||
|
bte = billable_time_entries
|
||||||
|
if bte.empty?
|
||||||
|
[nil, nil]
|
||||||
|
else
|
||||||
|
[bte.first.start, bte.last.end]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary
|
||||||
|
case type
|
||||||
|
when "fixed_cost"
|
||||||
|
[nil, nil, fixed_cost]
|
||||||
|
when "hourly_rate"
|
||||||
|
time_entries.inject([0.0, hourly_rate, 0.0]) do |summ, te|
|
||||||
|
summ[0] += te.total
|
||||||
|
summ[2] += te.total * hourly_rate
|
||||||
|
summ
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class TimeEntry < Base
|
class TimeEntry < Base
|
||||||
belongs_to :task
|
belongs_to :task
|
||||||
belongs_to :invoice
|
|
||||||
has_one :customer, :through => :task
|
has_one :customer, :through => :task
|
||||||
|
|
||||||
def total
|
def total
|
||||||
|
@ -77,26 +108,26 @@ module StopTime::Models
|
||||||
end
|
end
|
||||||
|
|
||||||
class Invoice < Base
|
class Invoice < Base
|
||||||
has_many :time_entries
|
has_many :tasks
|
||||||
|
has_many :time_entries, :through => :tasks
|
||||||
belongs_to :customer
|
belongs_to :customer
|
||||||
|
|
||||||
def summary
|
def summary
|
||||||
# FIXME: ensure that month is a DateTime/Time object.
|
# FIXME: ensure that month is a DateTime/Time object.
|
||||||
time_entries = self.time_entries.all
|
summ = {}
|
||||||
|
tasks.each { |task| summ[task.name] = task.summary }
|
||||||
|
return summ
|
||||||
|
end
|
||||||
|
|
||||||
tasks = time_entries.inject({}) do |tasks, entry|
|
def period
|
||||||
time = entry.total
|
p = [Time.now, Time.now]
|
||||||
if tasks.has_key? entry.task
|
tasks.each do |task|
|
||||||
tasks[entry.task][0] += time
|
tp = task.bill_period
|
||||||
tasks[entry.task][2] += time * entry.task.hourly_rate
|
p tp
|
||||||
else
|
p[0] = tp[0] if !tp[0].nil? and tp[0] < p[0]
|
||||||
tasks[entry.task] = [time, entry.task.hourly_rate,
|
p[1] = tp[1] if !tp[1].nil? and tp[1] > p[1]
|
||||||
time * entry.task.hourly_rate]
|
|
||||||
end
|
|
||||||
tasks
|
|
||||||
end
|
end
|
||||||
|
return p
|
||||||
return tasks
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -215,6 +246,20 @@ module StopTime::Models
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class ImprovedInvoiceSupport < V 1.7
|
||||||
|
def self.up
|
||||||
|
add_column(Task.table_name, :invoice_id, :integer)
|
||||||
|
remove_column(Task.table_name, :billed)
|
||||||
|
remove_column(TimeEntry.table_name, :invoice_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
remove_column(Task.table_name, :invoice_id, :integer)
|
||||||
|
add_column(Task.table_name, :billed, :boolean)
|
||||||
|
add_column(TimeEntry.table_name, :invoice_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
end # StopTime::Models
|
end # StopTime::Models
|
||||||
|
|
||||||
module StopTime::Controllers
|
module StopTime::Controllers
|
||||||
|
@ -363,7 +408,6 @@ module StopTime::Controllers
|
||||||
@task.fixed_cost = nil
|
@task.fixed_cost = nil
|
||||||
@task.hourly_rate = @input.hourly_rate
|
@task.hourly_rate = @input.hourly_rate
|
||||||
end
|
end
|
||||||
@task["billed"] = @input.has_key? "billed"
|
|
||||||
@task.save
|
@task.save
|
||||||
if @task.invalid?
|
if @task.invalid?
|
||||||
@errors = @task.errors
|
@errors = @task.errors
|
||||||
|
@ -379,21 +423,48 @@ module StopTime::Controllers
|
||||||
end
|
end
|
||||||
|
|
||||||
class CustomersNInvoices
|
class CustomersNInvoices
|
||||||
|
def get
|
||||||
|
render :invoices
|
||||||
|
end
|
||||||
|
|
||||||
def post(customer_id)
|
def post(customer_id)
|
||||||
return redirect R(CustomersN, customer_id) if @input.cancel
|
return redirect R(CustomersN, customer_id) if @input.cancel
|
||||||
|
|
||||||
# Create the invoice.
|
# Create the invoice.
|
||||||
last_id = Invoice.last ? Invoice.last.id : 0
|
# FIXME: make the sequence number reset on a new year.
|
||||||
number = ("%d%02d" % [Time.now.year, last_id + 1])
|
last = Invoice.last
|
||||||
|
number = if last
|
||||||
|
last_year = last.number.to_s[0..3].to_i
|
||||||
|
if Time.now.year > last_year
|
||||||
|
number = ("%d%02d" % [Time.now.year, 1])
|
||||||
|
else
|
||||||
|
number = last.number.succ
|
||||||
|
end
|
||||||
|
else
|
||||||
|
number = ("%d%02d" % [Time.now.year, 1])
|
||||||
|
end
|
||||||
invoice = Invoice.create(:number => number)
|
invoice = Invoice.create(:number => number)
|
||||||
|
invoice.customer = Customer.find(customer_id)
|
||||||
|
|
||||||
invoice.time_entry_ids = @input["entries"]
|
# Handle the hourly rated tasks first.
|
||||||
@input["tasks"].each do |task|
|
tasks = Hash.new { |h, k| h[k] = Array.new }
|
||||||
task = Task.find(task)
|
@input["time_entries"].each do |entry|
|
||||||
task.billed = true
|
time_entry = TimeEntry.find(entry)
|
||||||
|
tasks[time_entry.task] << time_entry
|
||||||
|
end unless @input["time_entries"].blank?
|
||||||
|
tasks.each_key do |task|
|
||||||
|
bill_task = task.clone # FIXME: depends on rails version!
|
||||||
|
task.time_entries = task.time_entries - tasks[task]
|
||||||
task.save
|
task.save
|
||||||
invoice.time_entries << task.time_entries
|
bill_task.time_entries = tasks[task]
|
||||||
|
bill_task.save
|
||||||
|
invoice.tasks << bill_task
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Then, handle the fixed cost tasks.
|
||||||
|
@input["tasks"].each do |task|
|
||||||
|
invoice.tasks << Task.find(task)
|
||||||
|
end unless @input["tasks"].blank?
|
||||||
invoice.save
|
invoice.save
|
||||||
|
|
||||||
redirect R(CustomersNInvoicesX, customer_id, number)
|
redirect R(CustomersNInvoicesX, customer_id, number)
|
||||||
|
@ -415,21 +486,20 @@ module StopTime::Controllers
|
||||||
@company = CompanyInfo.first
|
@company = CompanyInfo.first
|
||||||
@customer = Customer.find(customer_id)
|
@customer = Customer.find(customer_id)
|
||||||
@tasks = @invoice.summary
|
@tasks = @invoice.summary
|
||||||
# FIXME: dirty hack!
|
@period = @invoice.period
|
||||||
@month = @invoice.time_entries.first.start
|
|
||||||
|
|
||||||
if @format == "html"
|
if @format == "html"
|
||||||
render :invoice
|
render :invoice
|
||||||
elsif @format == "pdf"
|
elsif @format == "pdf"
|
||||||
pdf_file = PUBLIC_DIR + "#{@number}.pdf"
|
pdf_file = PUBLIC_DIR + "#{@number}.pdf"
|
||||||
unless pdf_file.exist?
|
unless pdf_file.exist?
|
||||||
_generate_invoice_pdf(@customer, @tasks, @number)
|
_generate_invoice_pdf(@number)
|
||||||
end
|
end
|
||||||
redirect(StaticX, pdf_file.basename)
|
redirect(StaticX, pdf_file.basename)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def _generate_invoice_pdf(customer, tasks, number)
|
def _generate_invoice_pdf(number)
|
||||||
template = TEMPLATE_DIR + "invoice.tex.erb"
|
template = TEMPLATE_DIR + "invoice.tex.erb"
|
||||||
tex_file = PUBLIC_DIR + "#{number}.tex"
|
tex_file = PUBLIC_DIR + "#{number}.tex"
|
||||||
|
|
||||||
|
@ -443,11 +513,18 @@ module StopTime::Controllers
|
||||||
class CustomersNInvoicesNew
|
class CustomersNInvoicesNew
|
||||||
def get(customer_id)
|
def get(customer_id)
|
||||||
@customer = Customer.find(customer_id)
|
@customer = Customer.find(customer_id)
|
||||||
@entries = @customer.time_entries.all(:order => "start ASC",
|
@hourly_rate_tasks = {}
|
||||||
:conditions => ["invoice_id IS NULL"])
|
@fixed_cost_tasks = {}
|
||||||
@fixed_cost_tasks = @customer.tasks.all(:order => "updated_at ASC",
|
@customer.unbilled_tasks.each do |task|
|
||||||
:conditions => ["fixed_cost IS NOT NULL AND billed = ?", 'f'])
|
case task.type
|
||||||
p @entries, @fixed_cost_tasks
|
when "fixed_cost"
|
||||||
|
total = task.time_entries.inject(0.0) { |s, te| s + te.total }
|
||||||
|
@fixed_cost_tasks[task] = total
|
||||||
|
when "hourly_rate"
|
||||||
|
time_entries = task.billable_time_entries
|
||||||
|
@hourly_rate_tasks[task] = time_entries
|
||||||
|
end
|
||||||
|
end
|
||||||
render :invoice_select_form
|
render :invoice_select_form
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -757,10 +834,7 @@ module StopTime::Views
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
li do
|
# FIXME: add link(s) to related invoice(s)
|
||||||
_form_input_checkbox("billed")
|
|
||||||
label "Billed!", :for => "billed"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
input :type => "submit", :name => @method, :value => @method.capitalize
|
input :type => "submit", :name => @method, :value => @method.capitalize
|
||||||
input :type => "submit", :name => "cancel", :value => "Cancel"
|
input :type => "submit", :name => "cancel", :value => "Cancel"
|
||||||
|
@ -786,9 +860,14 @@ module StopTime::Views
|
||||||
subtotal = 0.0
|
subtotal = 0.0
|
||||||
@tasks.each do |task, line|
|
@tasks.each do |task, line|
|
||||||
tr do
|
tr do
|
||||||
td { task.name }
|
td { task }
|
||||||
td { "%.2fh" % line[0] }
|
if line[0].nil? and line[1].nil?
|
||||||
td { "€ %.2f" % line[1] }
|
td "–"
|
||||||
|
td "–"
|
||||||
|
else
|
||||||
|
td { "%.2fh" % line[0] }
|
||||||
|
td { "€ %.2f" % line[1] }
|
||||||
|
end
|
||||||
td { "€ %.2f" % line[2] }
|
td { "€ %.2f" % line[2] }
|
||||||
end
|
end
|
||||||
subtotal += line[2]
|
subtotal += line[2]
|
||||||
|
@ -821,22 +900,26 @@ module StopTime::Views
|
||||||
table do
|
table do
|
||||||
tr do
|
tr do
|
||||||
th ""
|
th ""
|
||||||
th "Task"
|
|
||||||
th "Start"
|
th "Start"
|
||||||
th "End"
|
th "End"
|
||||||
th "Comment"
|
th "Comment"
|
||||||
th "Total"
|
th "Total"
|
||||||
th "Amount"
|
th "Amount"
|
||||||
end
|
end
|
||||||
@entries.each do |entry|
|
@hourly_rate_tasks.keys.each do |task|
|
||||||
tr do
|
tr do
|
||||||
td { _form_input_checkbox("entries[]", entry.id) }
|
td { _form_input_checkbox("tasks[]", task.id) }
|
||||||
td { entry.task.name }
|
td task.name, :colspan => 5
|
||||||
td { label entry.start, :for => "entries[]_#{entry.id}" }
|
end
|
||||||
td { entry.end }
|
@hourly_rate_tasks[task].each do |entry|
|
||||||
td { entry.comment }
|
tr do
|
||||||
td { entry.total }
|
td { _form_input_checkbox("time_entries[]", entry.id) }
|
||||||
td { entry.total * entry.task.hourly_rate }
|
td { label entry.start, :for => "time_entries[]_#{entry.id}" }
|
||||||
|
td { entry.end }
|
||||||
|
td { entry.comment }
|
||||||
|
td { entry.total }
|
||||||
|
td { entry.total * entry.task.hourly_rate }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -849,11 +932,11 @@ module StopTime::Views
|
||||||
th "Registered time"
|
th "Registered time"
|
||||||
th "Amount"
|
th "Amount"
|
||||||
end
|
end
|
||||||
@fixed_cost_tasks.each do |task|
|
@fixed_cost_tasks.keys.each do |task|
|
||||||
tr do
|
tr do
|
||||||
td { _form_input_checkbox("tasks[]", task.id) }
|
td { _form_input_checkbox("tasks[]", task.id) }
|
||||||
td { label task.name, :for => "tasks[]_#{task.id}" }
|
td { label task.name, :for => "tasks[]_#{task.id}" }
|
||||||
td "" # FIXME!
|
td { "%.2fh" % @fixed_cost_tasks[task] }
|
||||||
td { task.fixed_cost }
|
td { task.fixed_cost }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue