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:
Paul van Tilburg 2011-11-09 14:02:33 +01:00
parent 0f59b9ceae
commit 69d6424462
1 changed files with 133 additions and 50 deletions

View File

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