stoptime/stoptime.rb

2550 lines
84 KiB
Ruby
Raw Normal View History

#!/usr/bin/env camping
2012-06-06 11:28:59 +02:00
# encoding: UTF-8
#
2011-11-15 17:54:09 +01:00
# stoptime.rb - The Stop… Camping Time! time registration and invoicing application.
#
# Stop… Camping Time! is Copyright © 2011 Paul van Tilburg <paul@luon.net>
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 2 of the License, or (at your
# option) any later version.
require "action_view"
require "active_support"
require "camping"
2012-01-30 12:57:15 +01:00
require "camping/mab"
2012-01-25 15:52:46 +01:00
require "camping/ar"
require "pathname"
require "sass/plugin/rack"
Camping.goes :StopTime
2011-11-03 11:39:58 +01:00
unless defined? PUBLIC_DIR
# The directory with public data.
2011-11-03 11:39:58 +01:00
PUBLIC_DIR = Pathname.new(__FILE__).dirname.expand_path + "public"
# The directory with template data.
2011-11-03 11:39:58 +01:00
TEMPLATE_DIR = Pathname.new(__FILE__).dirname.expand_path + "templates"
# Set up the locales.
I18n.load_path += Dir[ File.join('locale', '*.yml') ]
# Set up SASS.
Sass::Plugin.options[:template_location] = "templates/sass"
2012-06-06 11:28:59 +02:00
# Set the default encodings.
if RUBY_VERSION =~ /^1\.9/
Encoding.default_external = Encoding::UTF_8
Encoding.default_internal = Encoding::UTF_8
end
# Set the default date(/time) format.
Time::DATE_FORMATS.merge!(
:default => "%Y-%m-%d %H:%M",
:month_and_year => "%B %Y",
:date_only => "%Y-%m-%d",
:time_only => "%H:%M",
:day_code => "%Y%m%d")
Date::DATE_FORMATS.merge!(
:default => "%Y-%m-%d",
:month_and_year => "%B %Y")
end
# = The main application module
module StopTime
# The parsed configuration (Hash).
attr_reader :config
# Override controller call handler so that the configuration is available
# for all controllers and views.
def service(*a)
@config = StopTime::Models::Config.instance
@format = @request.path_info[/.([^.]+)/, 1];
@headers["Content-Type"] = "text/html; charset=utf-8"
super(*a)
end
# Trap the HUP signal and reload the configuration.
Signal.trap("HUP") do
$stderr.puts "I: caught signal HUP, reloading config"
Models::Config.instance.reload
end
# Add support for PUT and DELETE.
use Rack::MethodOverride
# Enable SASS CSS generation from templates/sass.
2011-11-09 23:00:13 +01:00
use Sass::Plugin::Rack
# Create/migrate the database when needed.
def self.create
StopTime::Models.create_schema
end
end
# = The Stop… Camping Time! Markaby extensions
module StopTime::Mab
SUPPORTED = [:get, :post]
def mab_done(tag)
attrs = tag._attributes
# Fix up URLs (normally done by Camping::Mab::mab_done
[:href, :action, :src].map { |a| attrs[a] &&= self/attrs[a] }
# Transform underscores into dashs in class names
if attrs.has_key?(:class) and attrs[:class].present?
attrs[:class] = attrs[:class].gsub('_', '-')
end
# The followin method processing is only for form tags.
return super unless tag._name == :form
meth = attrs[:method]
attrs[:method] = 'post' if override = !SUPPORTED.include?(meth)
# Inject a hidden input element with the proper method to the tag block
# if the form method is unsupported.
tag._block do |orig_blk|
input :type => 'hidden', :name => '_method', :value => meth
orig_blk.call
end if override
return super
end
include Mab::Indentation
end
# = The Stop… Camping Time! models
module StopTime::Models
# The configuration model class
#
# This class contains the configuration overlaying overridden options for
# subdirectories such that for each directory the specific configuration
# can be found.
class Config
# There should only be a single configuration object (for reloading).
include Singleton
# The default configuation file. (FIXME: shouldn't be hardcoded!)
ConfigFile = File.dirname(__FILE__) + "/config.yaml"
# The default configuration. Note that the configuration of the root
# will be merged with this configuration.
DefaultConfig = { "invoice_id" => "%Y%N",
"invoice_template" => "invoice",
"hourly_rate" => 20.0,
"vat_rate" => 21.0 }
# Creates a new configuration object and loads the configuation.
# by reading the file @config.yaml@ on disk, parsing it, and
# performing a merge with the default config (DefaultConfig).
def initialize
@config = DefaultConfig.dup
cfg = nil
# Read and parse the configuration.
begin
File.open(ConfigFile, "r") { |file| cfg = YAML.load(file) }
rescue => e
$stderr.puts "E: couldn't read configuration file: #{e}"
end
# Merge the loaded config with the default config (if it's a Hash)
case cfg
when Hash
@config.merge! cfg if cfg
when nil, false
# It's ok, it is empty.
else
$stderr.puts "W: wrong format detected in configuration file!"
end
end
# Reloads the configuration file.
def reload
load
end
# Give access to the configuration.
def [](attr)
@config[attr]
end
end # class StopTime::Models::Config
# == The customer class
#
2011-11-28 12:46:48 +01:00
# This class represents a customer that has projects/tasks
# for which invoices need to be generated.
2011-11-28 12:46:48 +01:00
#
# === Attributes
#
# [id] unique identification number (Fixnum)
# [name] official (long) name (String)
# [short_name] abbreviated name (String)
# [financial_contact] name of the financial contact person/department (String)
2011-11-28 12:46:48 +01:00
# [address_street] street part of the address (String)
# [address_postal_code] zip/postal code part of the address (String)
# [address_city] city part of the postal code (String)
# [email] email address (String)
# [phone] phone number (String)
# [hourly_rate] default hourly rate (Float)
# [time_specification] whether the customer requires time specifications (TrueClass/FalseClass)
2011-11-28 12:46:48 +01:00
# [created_at] time of creation (Time)
# [updated_at] time of last update (Time)
#
# === Attributes by association
#
# [invoices] list of invoices (Array of Invoice)
# [tasks] list of tasks (Array of Task)
# [time_entries] list of time entries (Array of TimeEntry)
class Customer < Base
has_many :tasks
has_many :invoices
has_many :time_entries, :through => :tasks
2011-12-22 16:32:47 +01:00
# Returns the short name if set, otherwise the full name.
def shortest_name
short_name.present? ? short_name : name
end
# Returns a list of tasks that have not been billed via in invoice.
def unbilled_tasks
tasks.all(:conditions => ["invoice_id IS NULL"], :order => "name ASC")
end
end
# == The task class
#
# This class represents a task (or project) of a customer on which time can
# be registered.
# There are two types of classes: with an hourly and with a fixed cost.
2011-11-28 12:46:48 +01:00
#
# === Attributes
#
# [id] unique identification number (Fixnum)
# [name] description (String)
# [fixed_cost] fixed cost of the task (Float)
# [hourly_rate] hourly rate for the task (Float)
2012-09-28 10:09:42 +02:00
# [vat_rate] VAT rate at time of billing (Float)
# [invoice_comment] extra comment for the invoice (String)
2011-11-28 12:46:48 +01:00
# [created_at] time of creation (Time)
# [updated_at] time of last update (Time)
#
# === Attributes by association
#
# [customer] associated customer (Customer)
# [invoice] associated invoice if the task is billed (Invoice)
# [time_entries] list of registered time entries (Array of TimeEntry)
class Task < Base
has_many :time_entries
belongs_to :customer
belongs_to :invoice
# Determines whether the task has a fixed cost.
# When +false+ is returned, one can assume the task has an hourly rate.
def fixed_cost?
not self.fixed_cost.blank?
end
# Returns the type of the task, this is a String valued either
# "+fixed_cost+" or "+hourly_rate+".
def type
fixed_cost? ? "fixed_cost" : "hourly_rate"
end
# Returns a list of time entries that should be (and are not yet)
# billed.
def billable_time_entries
time_entries.all(:conditions => ["bill = 't'"], :order => "start ASC")
end
# Returns the bill period of the task by means of an Array containing
# the first and last Time object found for registered time on this
# task.
# If no time is registered, the last time the task has been updated
# is returned.
def bill_period
bte = billable_time_entries
if bte.empty?
# FIXME: better defaults?
[updated_at, updated_at]
else
[bte.first.start, bte.last.end]
end
end
# Returns whether the task is billed, i.e. included in an invoice.
def billed?
not invoice.nil?
end
# Returns a time and cost summary of the registered time on the task
# by means of Array of four values.
# In case of a fixed cost task, the first value is the total of time
# (in hours), the third value is the fixed cost, and the fourth value
# is the VAT.
# In case of a task with an hourly rate, the first value is
# the total of time (in hours), the second value is the hourly rate,
# the third value is the total amount (time times rate), and the fourth
# value is the VAT.
def summary
case type
when "fixed_cost"
total = time_entries.inject(0.0) { |summ, te| summ + te.hours_total }
[total, nil, fixed_cost, fixed_cost * (vat_rate/100.0)]
when "hourly_rate"
time_entries.inject([0.0, hourly_rate, 0.0, 0.0]) do |summ, te|
total_cost = te.hours_total * hourly_rate
summ[0] += te.hours_total
summ[2] += total_cost
summ[3] += total_cost * (vat_rate/100.0)
summ
end
end
end
# Returns an invoice comment if the task is billed and if it is
# set, otherwise the name.
def comment_or_name
if billed? and self.invoice_comment.present?
self.invoice_comment
else
self.name
end
end
end
# == The time entry class
#
# This class represents an amount of time that is registered on a certain
# task.
2011-11-28 12:46:48 +01:00
#
# === Attributes
#
# [id] unique identification number (Fixnum)
# [date] date of the entry (Time)
# [start] start time of the entry (Time)
# [end] finish time of the entry (Time)
# [bill] flag whether to bill or not (FalseClass/TrueClass)
# [comment] additional comment (String)
# [created_at] time of creation (Time)
# [updated_at] time of last update (Time)
#
# === Attributes by association
#
# [task] task the entry registers time for (Task)
# [customer] associated customer (Customer)
class TimeEntry < Base
belongs_to :task
has_one :customer, :through => :task
# Returns the total amount of time, the duration, in hours.
def hours_total
(self.end - self.start) / 1.hour
end
end
# == The invoice class
#
# This class represents an invoice for a customer that contains billed
# tasks and through the tasks registered time.
2011-11-28 12:46:48 +01:00
#
# === Attributes
#
# [id] unique identification number (Fixnum)
# [number] invoice number (Fixnum)
2011-11-28 12:55:13 +01:00
# [paid] flag whether the invoice has been paid (TrueClass/FalseClass)
# [include_specification] flag whether the invoice should include a time
# specification (TrueClass/FalseClass)
2011-11-28 12:46:48 +01:00
# [created_at] time of creation (Time)
# [updated_at] time of last update (Time)
#
# === Attributes by association
#
# [company_info] associated company info (CompanyInfo)
2011-11-28 12:46:48 +01:00
# [customer] associated customer (Customer)
# [tasks] billed tasks by the invoice (Array of Task)
# [time_entries] billed time entries (Array of TimeEntry)
class Invoice < Base
has_many :tasks
has_many :time_entries, :through => :tasks
belongs_to :customer
belongs_to :company_info
default_scope order('number DESC')
# Returns a time and cost summary of the contained tasks (Hash of
# Task to Array).
# See also Task#summary for the specification of the array.
def summary
summ = {}
tasks.each { |task| summ[task] = task.summary }
return summ
end
# Returns a total per VAT rate of the contained tasks (Hash of Float to
# Fixnum).
def vat_summary
vatsumm = Hash.new(0.0)
summary.each do |task, summ|
vatsumm[task.vat_rate] += summ[3]
end
return vatsumm
end
# Returns the invoice period based on the contained tasks (Array of Time).
# See also Task#bill_period.
def period
# FIXME: maybe should be updated_at?
return [created_at, created_at] if tasks.empty?
p = tasks.first.bill_period
tasks.each do |task|
tp = task.bill_period
p[0] = tp[0] if tp[0] < p[0]
p[1] = tp[1] if tp[1] > p[1]
end
return p
end
2012-01-09 17:38:06 +01:00
# Returns the total amount (including VAT).
def total_amount
subtotal, vattotal = summary.inject([0.0, 0.0]) do |tot, (task, summ)|
tot[0] += summ[2]
tot[1] += summ[3]
tot
end
2012-01-09 17:38:06 +01:00
if company_info.vatno.blank?
subtotal
else
subtotal + vattotal
2012-01-09 17:38:06 +01:00
end
end
# Returns if the invoice is past due (i.e. it has not been paid within
# the required amount of days).
def past_due?
not paid? and (Time.now - created_at) > 30.days # FIXME: hardcoded!
end
# Returns if the invoice is past due (i.e. it has not been paid within
# the required amount of days).
def way_past_due?
past_due? and (Time.now - created_at) > 2 * 30.days
end
end
# == The company information class
#
# This class contains information about the company or sole
# proprietorship of the user of Stop… Camping Time!
2011-11-28 12:46:48 +01:00
#
# === Attributes
#
# [id] unique identification number (Fixnum)
# [name] official company name (String)
# [contact_name] optional personal contact name (String)
# [address_street] street part of the address (String)
# [address_postal_code] zip/postal code part of the address (String)
# [address_city] city part of the postal code (String)
# [country] country of residence (String)
# [country_code] two letter country code (String)
# [email] email address (String)
# [phone] phone number (String)
# [cell] cellular phone number (String)
# [website] web address (String)
# [chamber] optional chamber of commerce ID number (String)
# [vatno] optional VAT number (String)
# [bank_name] name of the bank (String)
# [bank_bic] bank identification code (aka SWIFT code) (String)
2011-11-28 12:46:48 +01:00
# [accountname] name of the bank account holder (String)
# [accountno] number of the bank account (String)
# [accountiban] international bank account number (String)
2011-11-28 12:46:48 +01:00
# [created_at] time of creation (Time)
# [updated_at] time of last update (Time)
#
# === Attributes by association
#
# [invoices] associated invoices (Array of Invoice)
# [original] original (previous) revision (CompanyInfo)
class CompanyInfo < Base
belongs_to :original, :class_name => "CompanyInfo"
has_many :invoices
# Returns the revision number (Fixnum).
def revision
id
end
end
class StopTimeTables < V 1.0 # :nodoc:
def self.up
create_table Customer.table_name do |t|
2011-11-09 15:14:48 +01:00
t.string :name, :short_name,
:address_street, :address_postal_code, :address_city,
:email, :phone
t.timestamps
end
create_table Task.table_name do |t|
t.integer :customer_id
t.string :name
t.timestamps
end
create_table TimeEntry.table_name do |t|
t.integer :task_id, :invoice_id
t.datetime :start, :end
t.timestamps
end
end
def self.down
drop_table Customer.table_name
drop_table Task.table_name
drop_table TimeEntry.table_name
end
end
class CommentSupport < V 1.1 # :nodoc:
2011-11-03 22:23:50 +01:00
def self.up
add_column(TimeEntry.table_name, :comment, :string)
end
def self.down
remove_column(TimeEntry.table_name, :comment)
end
end
class BilledFlagSupport < V 1.2 # :nodoc:
def self.up
add_column(TimeEntry.table_name, :bill, :boolean)
end
def self.down
remove_column(TimeEntry.table_name, :bill)
end
end
class HourlyRateSupport < V 1.3 # :nodoc:
def self.up
add_column(Customer.table_name, :hourly_rate, :float,
:null => false,
:default => @config["hourly_rate"])
end
def self.down
remove_column(Customer.table_name, :hourly_rate)
end
end
class FixedCostTaskSupport < V 1.4 # :nodoc:
def self.up
add_column(Task.table_name, :billed, :boolean)
add_column(Task.table_name, :fixed_cost, :float)
add_column(Task.table_name, :hourly_rate, :float)
end
def self.down
remove_column(Task.table_name, :billed)
remove_column(Task.table_name, :fixed_cost)
remove_column(Task.table_name, :hourly_rate, :float)
end
end
class InvoiceSupport < V 1.5 # :nodoc:
def self.up
create_table Invoice.table_name do |t|
t.integer :number, :customer_id
t.boolean :payed
t.timestamps
end
end
def self.down
drop_table Invoice.table_name
end
end
class CompanyInfoSupport < V 1.6 # :nodoc:
def self.up
create_table CompanyInfo.table_name do |t|
t.string :name, :contact_name,
:address_street, :address_postal_code, :address_city,
:country, :country_code,
:phone, :cell, :email, :website,
:chamber, :vatno, :accountname, :accountno
t.timestamps
end
# Add company info record with defaults.
cinfo = CompanyInfo.create(:name => "My Company",
:contact_name => "Me",
:country => "The Netherlands",
:country_code => "NL")
cinfo.save
end
def self.down
drop_table CompanyInfo.table_name
end
end
class ImprovedInvoiceSupport < V 1.7 # :nodoc:
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
class TimeEntryDateSupport < V 1.8 # :nodoc:
def self.up
add_column(TimeEntry.table_name, :date, :datetime)
TimeEntry.all.each do |te|
te.date = te.start.at_beginning_of_day
te.save
end
end
def self.down
remove_column(TimeEntry.table_name, :date)
end
end
2011-11-28 12:55:13 +01:00
class PaidFlagTypoFix < V 1.9 # :nodoc:
def self.up
add_column(Invoice.table_name, :paid, :boolean)
Invoice.all.each do |i|
i.paid = i.payed unless i.payed.blank?
i.save
end
remove_column(Invoice.table_name, :payed)
end
def self.down
add_column(Invoice.table_name, :payed, :boolean)
Invoice.all.each do |i|
i.payed = i.paid unless i.paid.blank?
i.save
end
remove_column(Invoice.table_name, :paid)
end
end
class InvoiceCommentsSupport < V 1.91 # :nodoc:
def self.up
add_column(Task.table_name, :invoice_comment, :string)
end
def self.down
remove_column(Task.table_name, :invoice_comment)
end
end
class FinancialInfoSupport < V 1.92 # :nodoc:
def self.up
add_column(CompanyInfo.table_name, :bank_name, :string)
add_column(CompanyInfo.table_name, :bank_bic, :string)
add_column(CompanyInfo.table_name, :accountiban, :string)
add_column(Customer.table_name, :financial_contact, :string)
end
def self.down
remove_column(CompanyInfo.table_name, :bank_name)
remove_column(CompanyInfo.table_name, :bank_bic)
remove_column(CompanyInfo.table_name, :accountiban)
remove_column(Customer.table_name, :financial_contact)
end
end
class CompanyInfoRevisioning < V 1.93 # :nodoc:
def self.up
add_column(CompanyInfo.table_name, :original_id, :integer)
add_column(Invoice.table_name, :company_info_id, :integer)
ci = CompanyInfo.last
Invoice.all.each do |i|
i.company_info = ci
i.save
end
end
def self.down
remove_column(CompanyInfo.table_name, :original_id)
remove_column(Invoice.table_name, :company_info_id)
end
end
2012-09-28 10:09:42 +02:00
class VATRatePerTaskSupport < V 1.94 # :nodoc:
def self.up
add_column(Task.table_name, :vat_rate, :float)
config = Config.instance
Task.all.each do |t|
t.vat_rate = config['vat_rate']
t.save
end
end
def self.down
remove_column(Task.table_name, :vat_rate)
end
end
class TimeSpecificationSupport < V 1.95 # :nodoc:
def self.up
add_column(Customer.table_name, :time_specification, :boolean)
add_column(Invoice.table_name, :include_specification, :boolean)
end
def self.down
remove_column(Customer.table_name, :time_specification)
remove_column(Invoice.table_name, :include_specification)
end
end
2011-11-03 22:23:50 +01:00
end # StopTime::Models
# = The Stop… Camping Time! controllers
module StopTime::Controllers
# == The index controller
#
# Controller that presents the overview as the index, listing
# the running tasks and projects per customer.
#
# path:: /
# view:: Views#overview
class Index
# Shows an overview of all unbilled projects/tasks per customer using
# Views#overview.
def get
@tasks = {}
@task_count = 0
Customer.all.each do |customer|
tasks = customer.unbilled_tasks.sort_by { |t| t.name }