2011-10-31 14:36:01 +01:00
|
|
|
|
#!/usr/bin/env camping
|
2012-06-06 11:28:59 +02:00
|
|
|
|
# encoding: UTF-8
|
2011-10-31 14:36:01 +01:00
|
|
|
|
#
|
2011-11-15 17:54:09 +01:00
|
|
|
|
# stoptime.rb - The Stop… Camping Time! time registration and invoicing application.
|
2011-10-31 14:36:01 +01:00
|
|
|
|
#
|
|
|
|
|
# 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.
|
|
|
|
|
|
2011-11-09 18:31:23 +01:00
|
|
|
|
require "action_view"
|
2011-11-01 15:27:16 +01:00
|
|
|
|
require "active_support"
|
2011-10-31 14:36:01 +01:00
|
|
|
|
require "camping"
|
2012-01-30 12:57:15 +01:00
|
|
|
|
require "camping/mab"
|
2012-01-25 15:52:46 +01:00
|
|
|
|
require "camping/ar"
|
2011-10-31 14:36:01 +01:00
|
|
|
|
require "pathname"
|
2011-11-09 22:55:59 +01:00
|
|
|
|
require "sass/plugin/rack"
|
2011-10-31 14:36:01 +01:00
|
|
|
|
|
|
|
|
|
Camping.goes :StopTime
|
|
|
|
|
|
2011-11-03 11:39:58 +01:00
|
|
|
|
unless defined? PUBLIC_DIR
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# The directory with public data.
|
2011-11-03 11:39:58 +01:00
|
|
|
|
PUBLIC_DIR = Pathname.new(__FILE__).dirname.expand_path + "public"
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# The directory with template data.
|
2011-11-03 11:39:58 +01:00
|
|
|
|
TEMPLATE_DIR = Pathname.new(__FILE__).dirname.expand_path + "templates"
|
2011-11-03 10:30:02 +01:00
|
|
|
|
|
2011-11-09 18:31:23 +01:00
|
|
|
|
# Set up the locales.
|
|
|
|
|
I18n.load_path += Dir[ File.join('locale', '*.yml') ]
|
|
|
|
|
|
2011-11-09 22:55:59 +01:00
|
|
|
|
# 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
|
|
|
|
|
|
2011-11-01 15:27:16 +01:00
|
|
|
|
# Set the default date(/time) format.
|
2013-06-06 21:49:20 +02:00
|
|
|
|
Time::DATE_FORMATS.merge!(
|
2011-11-02 22:52:47 +01:00
|
|
|
|
:default => "%Y-%m-%d %H:%M",
|
|
|
|
|
:month_and_year => "%B %Y",
|
2011-11-09 15:13:39 +01:00
|
|
|
|
:date_only => "%Y-%m-%d",
|
2011-11-11 14:55:17 +01:00
|
|
|
|
:time_only => "%H:%M",
|
2011-11-03 11:40:58 +01:00
|
|
|
|
:day_code => "%Y%m%d")
|
2013-06-06 21:49:20 +02:00
|
|
|
|
Date::DATE_FORMATS.merge!(
|
2011-11-02 22:52:47 +01:00
|
|
|
|
:default => "%Y-%m-%d",
|
|
|
|
|
:month_and_year => "%B %Y")
|
2011-12-23 21:17:02 +01:00
|
|
|
|
end
|
2011-11-03 10:30:02 +01:00
|
|
|
|
|
2012-01-02 15:59:06 +01:00
|
|
|
|
# = The main application module
|
|
|
|
|
module StopTime
|
2011-11-10 18:24:11 +01:00
|
|
|
|
|
2011-12-23 21:17:02 +01:00
|
|
|
|
# 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
|
2012-01-20 00:36:19 +01:00
|
|
|
|
@format = @request.path_info[/.([^.]+)/, 1];
|
2013-06-20 22:06:52 +02:00
|
|
|
|
@headers["Content-Type"] = "text/html; charset=utf-8"
|
2011-12-23 21:17:02 +01:00
|
|
|
|
super(*a)
|
|
|
|
|
end
|
2011-11-10 18:24:11 +01:00
|
|
|
|
|
2012-01-02 15:59:06 +01:00
|
|
|
|
# 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
|
2011-10-31 14:36:01 +01:00
|
|
|
|
|
2012-01-20 00:35:44 +01:00
|
|
|
|
# Add support for PUT and DELETE.
|
|
|
|
|
use Rack::MethodOverride
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Enable SASS CSS generation from templates/sass.
|
2011-11-09 23:00:13 +01:00
|
|
|
|
use Sass::Plugin::Rack
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Create/migrate the database when needed.
|
2011-10-31 14:36:01 +01:00
|
|
|
|
def self.create
|
|
|
|
|
StopTime::Models.create_schema
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
end
|
|
|
|
|
|
2012-01-25 11:31:58 +01:00
|
|
|
|
# = The Stop… Camping Time! Markaby extensions
|
2012-01-30 13:57:51 +01:00
|
|
|
|
module StopTime::Mab
|
2013-06-23 22:28:28 +02:00
|
|
|
|
|
2012-01-30 13:57:51 +01:00
|
|
|
|
SUPPORTED = [:get, :post]
|
2012-01-30 13:57:51 +01:00
|
|
|
|
|
|
|
|
|
def mab_done(tag)
|
2013-06-23 22:28:28 +02:00
|
|
|
|
attrs = tag._attributes
|
|
|
|
|
|
|
|
|
|
# Fix up URLs (normally done by Camping::Mab::mab_done
|
|
|
|
|
[:href, :action, :src].map { |a| attrs[a] &&= self/attrs[a] }
|
|
|
|
|
|
2013-06-16 20:25:12 +02:00
|
|
|
|
# Transform underscores into dashs in class names
|
2013-06-23 22:28:28 +02:00
|
|
|
|
if attrs.has_key?(:class) and attrs[:class].present?
|
|
|
|
|
attrs[:class] = attrs[:class].gsub('_', '-')
|
2013-06-16 20:25:12 +02:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# The followin method processing is only for form tags.
|
2013-06-16 15:17:13 +02:00
|
|
|
|
return super unless tag._name == :form
|
2012-01-30 13:57:51 +01:00
|
|
|
|
|
2013-06-23 22:28:28 +02:00
|
|
|
|
meth = attrs[:method]
|
|
|
|
|
attrs[:method] = 'post' if override = !SUPPORTED.include?(meth)
|
2012-01-30 13:57:51 +01:00
|
|
|
|
# Inject a hidden input element with the proper method to the tag block
|
|
|
|
|
# if the form method is unsupported.
|
2013-06-16 15:17:13 +02:00
|
|
|
|
tag._block do |orig_blk|
|
2012-01-30 13:57:51 +01:00
|
|
|
|
input :type => 'hidden', :name => '_method', :value => meth
|
|
|
|
|
orig_blk.call
|
|
|
|
|
end if override
|
2013-06-23 22:28:28 +02:00
|
|
|
|
|
|
|
|
|
return super
|
2012-01-30 13:57:51 +01:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
include Mab::Indentation
|
2012-01-25 15:53:24 +01:00
|
|
|
|
|
2012-01-20 00:35:44 +01:00
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# = The Stop… Camping Time! models
|
2011-10-31 14:36:01 +01:00
|
|
|
|
module StopTime::Models
|
|
|
|
|
|
2011-12-23 21:17:02 +01:00
|
|
|
|
# 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
|
|
|
|
|
|
2012-01-02 15:13:18 +01:00
|
|
|
|
# There should only be a single configuration object (for reloading).
|
2011-12-23 21:17:02 +01:00
|
|
|
|
include Singleton
|
|
|
|
|
|
|
|
|
|
# The default configuation file. (FIXME: shouldn't be hardcoded!)
|
2012-01-02 13:03:45 +01:00
|
|
|
|
ConfigFile = File.dirname(__FILE__) + "/config.yaml"
|
2012-01-02 15:59:06 +01:00
|
|
|
|
|
2011-12-23 21:17:02 +01:00
|
|
|
|
# The default configuration. Note that the configuration of the root
|
|
|
|
|
# will be merged with this configuration.
|
2014-02-07 21:04:08 +01:00
|
|
|
|
DefaultConfig = { "invoice_id" => "%Y%N",
|
|
|
|
|
"invoice_template" => "invoice",
|
|
|
|
|
"hourly_rate" => 20.0,
|
|
|
|
|
"vat_rate" => 21.0 }
|
2011-12-23 21:17:02 +01:00
|
|
|
|
|
|
|
|
|
# Creates a new configuration object and loads the configuation.
|
2012-01-02 15:59:06 +01:00
|
|
|
|
# by reading the file @config.yaml@ on disk, parsing it, and
|
|
|
|
|
# performing a merge with the default config (DefaultConfig).
|
2011-12-23 21:17:02 +01:00
|
|
|
|
def initialize
|
2012-01-02 15:13:18 +01:00
|
|
|
|
@config = DefaultConfig.dup
|
2011-12-23 21:17:02 +01:00
|
|
|
|
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
|
2012-01-02 13:03:45 +01:00
|
|
|
|
# Merge the loaded config with the default config (if it's a Hash)
|
|
|
|
|
case cfg
|
|
|
|
|
when Hash
|
2012-01-02 15:13:18 +01:00
|
|
|
|
@config.merge! cfg if cfg
|
2012-01-02 13:03:45 +01:00
|
|
|
|
when nil, false
|
|
|
|
|
# It's ok, it is empty.
|
|
|
|
|
else
|
|
|
|
|
$stderr.puts "W: wrong format detected in configuration file!"
|
|
|
|
|
end
|
2011-12-23 21:17:02 +01:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Reloads the configuration file.
|
|
|
|
|
def reload
|
|
|
|
|
load
|
|
|
|
|
end
|
|
|
|
|
|
2012-01-02 15:13:33 +01:00
|
|
|
|
# Give access to the configuration.
|
|
|
|
|
def [](attr)
|
|
|
|
|
@config[attr]
|
|
|
|
|
end
|
|
|
|
|
|
2011-12-23 21:17:02 +01:00
|
|
|
|
end # class StopTime::Models::Config
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# == The customer class
|
|
|
|
|
#
|
2011-11-28 12:46:48 +01:00
|
|
|
|
# This class represents a customer that has projects/tasks
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# 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)
|
2012-01-03 16:49:14 +01:00
|
|
|
|
# [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)
|
2013-07-13 22:31:41 +02:00
|
|
|
|
# [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)
|
2011-10-31 14:36:01 +01:00
|
|
|
|
class Customer < Base
|
|
|
|
|
has_many :tasks
|
2011-11-09 14:02:33 +01:00
|
|
|
|
has_many :invoices
|
2011-11-02 22:52:47 +01:00
|
|
|
|
has_many :time_entries, :through => :tasks
|
2011-11-09 14:02:33 +01:00
|
|
|
|
|
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
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Returns a list of tasks that have not been billed via in invoice.
|
2011-11-09 14:02:33 +01:00
|
|
|
|
def unbilled_tasks
|
2011-11-29 16:46:44 +01:00
|
|
|
|
tasks.all(:conditions => ["invoice_id IS NULL"], :order => "name ASC")
|
2011-11-09 14:02:33 +01:00
|
|
|
|
end
|
2011-10-31 14:36:01 +01:00
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# == 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)
|
2011-12-02 22:15:58 +01:00
|
|
|
|
# [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)
|
2011-10-31 14:36:01 +01:00
|
|
|
|
class Task < Base
|
|
|
|
|
has_many :time_entries
|
2011-11-01 15:29:55 +01:00
|
|
|
|
belongs_to :customer
|
2011-11-09 14:02:33 +01:00
|
|
|
|
belongs_to :invoice
|
2011-11-07 10:24:12 +01:00
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Determines whether the task has a fixed cost.
|
|
|
|
|
# When +false+ is returned, one can assume the task has an hourly rate.
|
2011-11-07 10:24:12 +01:00
|
|
|
|
def fixed_cost?
|
|
|
|
|
not self.fixed_cost.blank?
|
|
|
|
|
end
|
2011-11-07 13:38:07 +01:00
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Returns the type of the task, this is a String valued either
|
|
|
|
|
# "+fixed_cost+" or "+hourly_rate+".
|
2011-11-09 14:07:31 +01:00
|
|
|
|
def type
|
2011-11-07 13:38:07 +01:00
|
|
|
|
fixed_cost? ? "fixed_cost" : "hourly_rate"
|
|
|
|
|
end
|
2011-11-09 14:02:33 +01:00
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Returns a list of time entries that should be (and are not yet)
|
|
|
|
|
# billed.
|
2011-11-09 14:02:33 +01:00
|
|
|
|
def billable_time_entries
|
|
|
|
|
time_entries.all(:conditions => ["bill = 't'"], :order => "start ASC")
|
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# 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.
|
2011-11-09 14:02:33 +01:00
|
|
|
|
def bill_period
|
|
|
|
|
bte = billable_time_entries
|
|
|
|
|
if bte.empty?
|
2011-11-09 16:02:04 +01:00
|
|
|
|
# FIXME: better defaults?
|
2011-11-09 15:14:09 +01:00
|
|
|
|
[updated_at, updated_at]
|
2011-11-09 14:02:33 +01:00
|
|
|
|
else
|
|
|
|
|
[bte.first.start, bte.last.end]
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Returns whether the task is billed, i.e. included in an invoice.
|
2011-11-09 16:23:48 +01:00
|
|
|
|
def billed?
|
|
|
|
|
not invoice.nil?
|
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Returns a time and cost summary of the registered time on the task
|
2012-09-28 11:56:36 +02:00
|
|
|
|
# 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.
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# 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,
|
2012-09-28 11:56:36 +02:00
|
|
|
|
# the third value is the total amount (time times rate), and the fourth
|
|
|
|
|
# value is the VAT.
|
2011-11-09 14:02:33 +01:00
|
|
|
|
def summary
|
|
|
|
|
case type
|
|
|
|
|
when "fixed_cost"
|
2011-11-29 16:47:16 +01:00
|
|
|
|
total = time_entries.inject(0.0) { |summ, te| summ + te.hours_total }
|
2012-09-28 11:56:36 +02:00
|
|
|
|
[total, nil, fixed_cost, fixed_cost * (vat_rate/100.0)]
|
2011-11-09 14:02:33 +01:00
|
|
|
|
when "hourly_rate"
|
2012-09-28 11:56:36 +02:00
|
|
|
|
time_entries.inject([0.0, hourly_rate, 0.0, 0.0]) do |summ, te|
|
|
|
|
|
total_cost = te.hours_total * hourly_rate
|
2011-11-09 15:12:29 +01:00
|
|
|
|
summ[0] += te.hours_total
|
2012-09-28 11:56:36 +02:00
|
|
|
|
summ[2] += total_cost
|
|
|
|
|
summ[3] += total_cost * (vat_rate/100.0)
|
2011-11-09 14:02:33 +01:00
|
|
|
|
summ
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
end
|
2011-12-02 22:16:27 +01:00
|
|
|
|
|
|
|
|
|
# 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
|
2011-10-31 14:36:01 +01:00
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# == 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)
|
2011-10-31 14:36:01 +01:00
|
|
|
|
class TimeEntry < Base
|
|
|
|
|
belongs_to :task
|
2011-11-07 13:38:07 +01:00
|
|
|
|
has_one :customer, :through => :task
|
2011-11-07 17:41:46 +01:00
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# Returns the total amount of time, the duration, in hours.
|
2011-11-09 15:12:29 +01:00
|
|
|
|
def hours_total
|
2011-11-07 17:41:46 +01:00
|
|
|
|
(self.end - self.start) / 1.hour
|
|
|
|
|
end
|
2011-11-07 10:24:12 +01:00
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# == 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)
|
2013-07-13 22:31:41 +02:00
|
|
|
|
# [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
|
|
|
|
|
#
|
2012-01-09 15:48:20 +01:00
|
|
|
|
# [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)
|
2011-11-07 10:24:12 +01:00
|
|
|
|
class Invoice < Base
|
2011-11-09 14:02:33 +01:00
|
|
|
|
has_many :tasks
|
|
|
|
|
has_many :time_entries, :through => :tasks
|
2011-11-07 10:24:12 +01:00
|
|
|
|
belongs_to :customer
|
2012-01-09 15:48:20 +01:00
|
|
|
|
belongs_to :company_info
|
2014-02-07 21:22:20 +01:00
|
|
|
|
default_scope order('number DESC')
|
2011-11-07 17:41:46 +01:00
|
|
|
|
|
2011-12-02 22:17:27 +01:00
|
|
|
|
# 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.
|
2011-11-07 17:41:46 +01:00
|
|
|
|
def summary
|
2011-11-09 14:02:33 +01:00
|
|
|
|
summ = {}
|
2011-12-02 22:17:27 +01:00
|
|
|
|
tasks.each { |task| summ[task] = task.summary }
|
2011-11-09 14:02:33 +01:00
|
|
|
|
return summ
|
|
|
|
|
end
|
2011-11-07 17:41:46 +01:00
|
|
|
|
|
2012-09-28 11:57:14 +02:00
|
|
|
|
# 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
|
|
|
|
|
|
2011-12-02 22:17:27 +01:00
|
|
|
|
# Returns the invoice period based on the contained tasks (Array of Time).
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# See also Task#bill_period.
|
2011-11-09 14:02:33 +01:00
|
|
|
|
def period
|
2011-11-09 16:02:04 +01:00
|
|
|
|
# FIXME: maybe should be updated_at?
|
|
|
|
|
return [created_at, created_at] if tasks.empty?
|
2011-11-09 15:14:09 +01:00
|
|
|
|
p = tasks.first.bill_period
|
2011-11-09 14:02:33 +01:00
|
|
|
|
tasks.each do |task|
|
|
|
|
|
tp = task.bill_period
|
2011-11-09 15:14:09 +01:00
|
|
|
|
p[0] = tp[0] if tp[0] < p[0]
|
|
|
|
|
p[1] = tp[1] if tp[1] > p[1]
|
2011-11-09 14:02:33 +01:00
|
|
|
|
end
|
|
|
|
|
return p
|
2011-11-07 17:41:46 +01:00
|
|
|
|
end
|
2012-01-09 17:38:06 +01:00
|
|
|
|
|
|
|
|
|
# Returns the total amount (including VAT).
|
|
|
|
|
def total_amount
|
2012-09-28 11:58:38 +02:00
|
|
|
|
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
|
2012-09-28 11:58:38 +02:00
|
|
|
|
subtotal + vattotal
|
2012-01-09 17:38:06 +01:00
|
|
|
|
end
|
|
|
|
|
end
|
2013-06-16 23:29:01 +02:00
|
|
|
|
|
|
|
|
|
# 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
|
2011-10-31 14:36:01 +01:00
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# == 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)
|
2012-01-03 16:49:14 +01:00
|
|
|
|
# [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)
|
2012-01-03 16:49:14 +01:00
|
|
|
|
# [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)
|
2012-01-09 15:48:20 +01:00
|
|
|
|
#
|
|
|
|
|
# === Attributes by association
|
|
|
|
|
#
|
|
|
|
|
# [invoices] associated invoices (Array of Invoice)
|
|
|
|
|
# [original] original (previous) revision (CompanyInfo)
|
2011-11-07 14:54:11 +01:00
|
|
|
|
class CompanyInfo < Base
|
2012-01-09 15:48:20 +01:00
|
|
|
|
belongs_to :original, :class_name => "CompanyInfo"
|
|
|
|
|
has_many :invoices
|
|
|
|
|
|
|
|
|
|
# Returns the revision number (Fixnum).
|
|
|
|
|
def revision
|
|
|
|
|
id
|
|
|
|
|
end
|
2011-11-07 14:54:11 +01:00
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
class StopTimeTables < V 1.0 # :nodoc:
|
2011-10-31 14:36:01 +01:00
|
|
|
|
def self.up
|
|
|
|
|
create_table Customer.table_name do |t|
|
2011-11-09 15:14:48 +01:00
|
|
|
|
t.string :name, :short_name,
|
2011-10-31 14:36:01 +01:00
|
|
|
|
: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|
|
2011-11-07 10:24:12 +01:00
|
|
|
|
t.integer :task_id, :invoice_id
|
2011-10-31 14:36:01 +01:00
|
|
|
|
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
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
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
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
class BilledFlagSupport < V 1.2 # :nodoc:
|
2011-11-03 23:07:42 +01:00
|
|
|
|
def self.up
|
|
|
|
|
add_column(TimeEntry.table_name, :bill, :boolean)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def self.down
|
|
|
|
|
remove_column(TimeEntry.table_name, :bill)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
class HourlyRateSupport < V 1.3 # :nodoc:
|
2011-11-03 23:44:06 +01:00
|
|
|
|
def self.up
|
|
|
|
|
add_column(Customer.table_name, :hourly_rate, :float,
|
2011-12-23 21:17:44 +01:00
|
|
|
|
:null => false,
|
|
|
|
|
:default => @config["hourly_rate"])
|
2011-11-03 23:44:06 +01:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def self.down
|
|
|
|
|
remove_column(Customer.table_name, :hourly_rate)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
class FixedCostTaskSupport < V 1.4 # :nodoc:
|
2011-11-07 10:24:12 +01:00
|
|
|
|
def self.up
|
|
|
|
|
add_column(Task.table_name, :billed, :boolean)
|
2011-11-07 10:44:35 +01:00
|
|
|
|
add_column(Task.table_name, :fixed_cost, :float)
|
2011-11-07 13:38:07 +01:00
|
|
|
|
add_column(Task.table_name, :hourly_rate, :float)
|
2011-11-07 10:24:12 +01:00
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
def self.down
|
2011-11-07 10:44:35 +01:00
|
|
|
|
remove_column(Task.table_name, :billed)
|
|
|
|
|
remove_column(Task.table_name, :fixed_cost)
|
2011-11-07 13:38:07 +01:00
|
|
|
|
remove_column(Task.table_name, :hourly_rate, :float)
|
2011-11-07 10:24:12 +01:00
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
class InvoiceSupport < V 1.5 # :nodoc:
|
2011-11-07 10:24:12 +01:00
|
|
|
|
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
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
class CompanyInfoSupport < V 1.6 # :nodoc:
|
2011-11-07 14:54:11 +01:00
|
|
|
|
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
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
class ImprovedInvoiceSupport < V 1.7 # :nodoc:
|
2011-11-09 14:02:33 +01:00
|
|
|
|
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
|
|
|
|
|
|
2011-11-11 14:55:17 +01:00
|
|
|
|
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
|
|
|
|
|
|
2011-12-02 22:15:58 +01:00
|
|
|
|
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
|
|
|
|
|
|
2012-01-03 16:49:14 +01:00
|
|
|
|
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
|
|
|
|
|
|
2012-01-09 15:48:20 +01:00
|
|
|
|
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
|
|
|
|
|
|
2013-07-13 22:31:41 +02:00
|
|
|
|
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
|
2011-10-31 14:36:01 +01:00
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# = The Stop… Camping Time! controllers
|
2011-10-31 14:36:01 +01:00
|
|
|
|
module StopTime::Controllers
|
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# == The index controller
|
|
|
|
|
#
|
|
|
|
|
# Controller that presents the overview as the index, listing
|
|
|
|
|
# the running tasks and projects per customer.
|
|
|
|
|
#
|
|
|
|
|
# path:: /
|
|
|
|
|
# view:: Views#overview
|
2011-10-31 14:36:01 +01:00
|
|
|
|
class Index
|
2011-12-10 21:13:26 +01:00
|
|
|
|
# Shows an overview of all unbilled projects/tasks per customer using
|
|
|
|
|
# Views#overview.
|
2011-10-31 14:36:01 +01:00
|
|
|
|
def get
|
2011-11-09 16:02:43 +01:00
|
|
|
|
@tasks = {}
|
2013-06-20 22:06:28 +02:00
|
|
|
|
@task_count = 0
|
2011-11-09 16:02:43 +01:00
|
|
|
|
Customer.all.each do |customer|
|
2013-06-20 22:06:28 +02:00
|
|
|
|
tasks = customer.unbilled_tasks.sort_by { |t| t.name }
|
|
|
|
|