2014-11-01 21:37:41 +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
|
|
|
|
#
|
2015-06-20 18:31:40 +02: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!(
|
2015-06-20 19:36:30 +02:00
|
|
|
|
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")
|
2013-06-06 21:49:20 +02:00
|
|
|
|
Date::DATE_FORMATS.merge!(
|
2015-06-20 19:36:30 +02: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
|
|
|
|
|
2014-10-19 21:37:20 +02:00
|
|
|
|
# The version of the application
|
2018-10-15 19:49:38 +02:00
|
|
|
|
VERSION = '1.17.1'
|
2014-10-19 21:37:20 +02:00
|
|
|
|
puts "Starting Stop… Camping Time! version #{VERSION}"
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @return [Hash{String=>Object}] The parsed configuration.
|
2011-12-23 21:17:02 +01:00
|
|
|
|
attr_reader :config
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Overrides controller call handler so that the configuration is available
|
2011-12-23 21:17:02 +01:00
|
|
|
|
# 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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @return [void]
|
2011-10-31 14:36:01 +01:00
|
|
|
|
def self.create
|
|
|
|
|
StopTime::Models.create_schema
|
|
|
|
|
end
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
end # module StopTime
|
2011-10-31 14:36:01 +01:00
|
|
|
|
|
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
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# List of support methods supported by the server.
|
2012-01-30 13:57:51 +01:00
|
|
|
|
SUPPORTED = [:get, :post]
|
2012-01-30 13:57:51 +01:00
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# When a tag is completed (i.e. closed), perform some post-processing
|
|
|
|
|
# on the tag's attributes.
|
|
|
|
|
#
|
|
|
|
|
# * Fix up URLs in attributes by applying +Camping::Helpers#/+ to them.
|
|
|
|
|
# * Change action methods besides the supported methods (see {SUPPORTED})
|
|
|
|
|
# to +:post+ and add a hidden +_method+ input that still contains
|
|
|
|
|
# the original method.
|
|
|
|
|
# * Replace underscores in the class attribute by dashes (needed for
|
|
|
|
|
# Bootstrap).
|
|
|
|
|
#
|
|
|
|
|
# @param [Mab::Mixin::Tag] tag the tag that is completed
|
|
|
|
|
# @return [Mab::Mixin::Tag] the post-processed tag
|
2012-01-30 13:57:51 +01:00
|
|
|
|
def mab_done(tag)
|
2013-06-23 22:28:28 +02:00
|
|
|
|
attrs = tag._attributes
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Fix up URLs (normally done by Camping::Mab::mab_done)
|
2013-06-23 22:28:28 +02:00
|
|
|
|
[:href, :action, :src].map { |a| attrs[a] &&= self/attrs[a] }
|
|
|
|
|
|
2015-06-20 19:36:30 +02:00
|
|
|
|
# Transform underscores into dashes in all attributes
|
|
|
|
|
attrs.select { |attr_sym, _| attr_sym.to_s =~ /_/ } \
|
|
|
|
|
.each do |attr_sym, _|
|
|
|
|
|
new_attr_sym = attr_sym.to_s.gsub('_', '-').to_sym
|
|
|
|
|
attrs[new_attr_sym] = attrs.delete(attr_sym)
|
|
|
|
|
end
|
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|
|
2015-06-20 19:36:30 +02:00
|
|
|
|
input type: "hidden", name: "_method", value: meth
|
2012-01-30 13:57:51 +01:00
|
|
|
|
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
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
end # module StopTime::Mab
|
2012-01-20 00:35:44 +01:00
|
|
|
|
|
2011-11-10 18:24:11 +01:00
|
|
|
|
# = The Stop… Camping Time! models
|
2011-10-31 14:36:01 +01:00
|
|
|
|
module StopTime::Models
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# == The configuration class
|
2011-12-23 21:17:02 +01:00
|
|
|
|
#
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# This class contains the application configuration constructed by
|
|
|
|
|
# default options (see {DefaultConfig}) merged with the configuration
|
|
|
|
|
# found in the configuration file (see {ConfigFile}).
|
2011-12-23 21:17:02 +01:00
|
|
|
|
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,
|
2014-10-25 21:35:53 +02:00
|
|
|
|
"time_resolution" => 1,
|
2016-02-26 23:12:36 +01:00
|
|
|
|
"date_new_entry" => "today",
|
2014-02-07 21:04:08 +01:00
|
|
|
|
"vat_rate" => 21.0 }
|
2011-12-23 21:17:02 +01:00
|
|
|
|
|
|
|
|
|
# Creates a new configuration object and loads the configuation.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# by reading the file +config.yaml+ on disk (see {ConfigFile}, parsing
|
|
|
|
|
# it, and performing a merge with the default config (see
|
|
|
|
|
# {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
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Returns the value for the given configuration option.
|
|
|
|
|
#
|
|
|
|
|
# @param [String] option a configuration option
|
|
|
|
|
# @return [Object] the value for the given configuration option
|
|
|
|
|
def [](option)
|
|
|
|
|
@config[option]
|
2012-01-02 15:13:33 +01:00
|
|
|
|
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-10-31 14:36:01 +01:00
|
|
|
|
class Customer < Base
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute id [r]
|
|
|
|
|
# @return [Fixnum] unique identification number
|
|
|
|
|
# @!attribute name
|
|
|
|
|
# @return [String] official (long) name
|
|
|
|
|
# @!attribute short_name
|
|
|
|
|
# @return [String] abbreviated name
|
|
|
|
|
# @!attribute financial_contact
|
|
|
|
|
# @return [String] name of the financial contact person/department
|
|
|
|
|
# @!attribute address_street
|
|
|
|
|
# @return [String] street part of the address
|
|
|
|
|
# @!attribute address_postal_code
|
|
|
|
|
# @return [String] zip/postal code part of the address
|
|
|
|
|
# @!attribute address_city
|
|
|
|
|
# @return [String] city part of the postal code
|
|
|
|
|
# @!attribute email
|
|
|
|
|
# @return [String] email address
|
|
|
|
|
# @!attribute phone
|
|
|
|
|
# @return [String] phone number
|
|
|
|
|
# @!attribute hourly_rate
|
|
|
|
|
# @return [Float] default hourly rate
|
|
|
|
|
# @!attribute time_specification
|
2015-06-20 18:31:40 +02:00
|
|
|
|
# @return [Boolean] flag whether the customer requires time
|
|
|
|
|
# specifications
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute created_at
|
|
|
|
|
# @return [Time] time of creation
|
|
|
|
|
# @!attribute updated_at
|
|
|
|
|
# @return [Time] time of last update
|
|
|
|
|
|
|
|
|
|
# @!attribute invoices
|
|
|
|
|
# @return [Array<Invoice>] associated invoices
|
2011-10-31 14:36:01 +01:00
|
|
|
|
has_many :tasks
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute tasks
|
|
|
|
|
# @return [Array<Task>] associated tasks
|
2011-11-09 14:02:33 +01:00
|
|
|
|
has_many :invoices
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute time_entries
|
|
|
|
|
# @return [Array<TimeEntry>] associated time entries
|
2015-06-20 19:36:30 +02: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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [String] the shortest name
|
2011-12-22 16:32:47 +01:00
|
|
|
|
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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Array<Task>] associated unbilled tasks
|
2011-11-09 14:02:33 +01:00
|
|
|
|
def unbilled_tasks
|
2014-10-18 21:25:26 +02:00
|
|
|
|
tasks.where("invoice_id IS NULL").order("name ASC")
|
2011-11-09 14:02:33 +01:00
|
|
|
|
end
|
2014-11-01 17:45:16 +01:00
|
|
|
|
|
|
|
|
|
# Returns a list of tasks that are active, i.e. that have not been
|
|
|
|
|
# billed and are either fixed cost or have some registered time.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Array<Task>] associated active tasks
|
2014-11-01 17:45:16 +01:00
|
|
|
|
def active_tasks
|
2014-11-01 21:37:41 +01:00
|
|
|
|
unbilled_tasks.select do |task|
|
|
|
|
|
task.fixed_cost? or task.time_entries.present?
|
|
|
|
|
end
|
2014-11-01 17:45:16 +01:00
|
|
|
|
end
|
2014-11-01 21:37:41 +01:00
|
|
|
|
end # class StopTime::Models::Customer
|
2011-10-31 14:36:01 +01:00
|
|
|
|
|
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-10-31 14:36:01 +01:00
|
|
|
|
class Task < Base
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute id [r]
|
|
|
|
|
# @return [Fixnum] unique identification number
|
|
|
|
|
# @!attribute name
|
|
|
|
|
# @return [String] description
|
|
|
|
|
# @!attribute fixed_cost
|
|
|
|
|
# @return [Float] fixed cost of the task
|
|
|
|
|
# @!attribute hourly_rate
|
|
|
|
|
# @return [Float] hourly rate for the task
|
|
|
|
|
# @!attribute vat_rate
|
|
|
|
|
# @return [Float] VAT rate at time of billing
|
|
|
|
|
# @!attribute invoice_comment
|
|
|
|
|
# @return [String] extra comment for the invoice
|
|
|
|
|
# @!attribute created_at
|
|
|
|
|
# @return [Time] time of creation
|
|
|
|
|
# @!attribute updated_at
|
|
|
|
|
# @return [Time] time of last update
|
|
|
|
|
|
|
|
|
|
# @!attribute customer
|
|
|
|
|
# @return [Customer] associated customer
|
2011-11-01 15:29:55 +01:00
|
|
|
|
belongs_to :customer
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute time_entries
|
|
|
|
|
# @return [Array<TimeEntry>] associated registered time entries
|
|
|
|
|
has_many :time_entries
|
|
|
|
|
# @!attribute invoice
|
|
|
|
|
# @return [Invoice, nil] associated invoice if the task is billed,
|
|
|
|
|
# +nil+ otherwise
|
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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Boolean] whether the task has a fixed cost
|
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
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Returns the type of the task
|
|
|
|
|
#
|
|
|
|
|
# @return ["fixed_cose", "hourly_rate"] the type of the task
|
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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Array<TimeEntry>] associated billable time entries
|
2011-11-09 14:02:33 +01:00
|
|
|
|
def billable_time_entries
|
2014-10-18 21:25:26 +02:00
|
|
|
|
time_entries.where("bill = 't'").order("start ASC")
|
2011-11-09 14:02:33 +01:00
|
|
|
|
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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Array(Time, Time)] the bill period of the task
|
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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Boolean] whether the task is billed
|
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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Array(Float, Float, Float, Float)] the summary of the task
|
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.
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [String] the invoice comment or task name (if not billed)
|
2011-12-02 22:16:27 +01:00
|
|
|
|
def comment_or_name
|
|
|
|
|
if billed? and self.invoice_comment.present?
|
|
|
|
|
self.invoice_comment
|
|
|
|
|
else
|
|
|
|
|
self.name
|
|
|
|
|
end
|
|
|
|
|
end
|
2014-11-01 21:37:41 +01:00
|
|
|
|
end # class StopTime::Models::Task
|
2011-10-31 14:36:01 +01:00
|
|
|
|
|
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-10-31 14:36:01 +01:00
|
|
|
|
class TimeEntry < Base
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute id [r]
|
|
|
|
|
# @return [Fixnum] unique identification number
|
|
|
|
|
# @!attribute date
|
|
|
|
|
# @return [Time] date of the entry
|
|
|
|
|
# @!attribute start
|
|
|
|
|
# @return [Time] start time of the entry
|
|
|
|
|
# @!attribute end
|
|
|
|
|
# @return [Time] finish time of the entry
|
|
|
|
|
# @!attribute bill
|
|
|
|
|
# @return [Boolean] flag whether to bill or not
|
|
|
|
|
# @!attribute comment
|
|
|
|
|
# @return [String] additional comment
|
|
|
|
|
# @!attribute created_at
|
|
|
|
|
# @return [Time] time of creation
|
|
|
|
|
# @!attribute updated_at
|
|
|
|
|
# @return [Time] time of last update
|
|
|
|
|
|
|
|
|
|
# @!attribute task
|
|
|
|
|
# @return [Task] associated task the time is registered for
|
2011-10-31 14:36:01 +01:00
|
|
|
|
belongs_to :task
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute customer
|
|
|
|
|
# @return [Customer] associated customer
|
2015-06-20 19:36:30 +02:00
|
|
|
|
has_one :customer, through: :task
|
2011-11-07 17:41:46 +01:00
|
|
|
|
|
2014-10-25 21:55:23 +02:00
|
|
|
|
before_validation :round_start_end
|
|
|
|
|
|
2014-10-12 17:04:18 +02:00
|
|
|
|
# Returns the total amount of time, the duration, in hours (up to
|
|
|
|
|
# 2 decimals only!).
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Float] the total amount of registered time
|
2011-11-09 15:12:29 +01:00
|
|
|
|
def hours_total
|
2014-10-12 17:04:18 +02:00
|
|
|
|
((self.end - self.start) / 1.hour).round(2)
|
2011-11-07 17:41:46 +01:00
|
|
|
|
end
|
2014-10-25 21:53:30 +02:00
|
|
|
|
|
2014-11-01 22:09:21 +01:00
|
|
|
|
def in_current_month?
|
|
|
|
|
self.end.month == Time.now.month
|
|
|
|
|
end
|
|
|
|
|
|
2014-10-25 21:55:23 +02:00
|
|
|
|
#########
|
|
|
|
|
protected
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Rounds the start and end time to the configured resolution using
|
|
|
|
|
# {#round_time).
|
|
|
|
|
#
|
|
|
|
|
# @return [void]
|
2014-10-25 21:55:23 +02:00
|
|
|
|
def round_start_end
|
|
|
|
|
self.start = round_time(self.start)
|
|
|
|
|
self.end = round_time(self.end)
|
|
|
|
|
end
|
|
|
|
|
|
2014-10-25 21:53:30 +02:00
|
|
|
|
#######
|
|
|
|
|
private
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Rounds the time using the configured time resolution.
|
|
|
|
|
#
|
|
|
|
|
# @param [Time] t the input time
|
|
|
|
|
# @return [Time] the rounded time
|
2014-10-25 21:53:30 +02:00
|
|
|
|
def round_time(t)
|
|
|
|
|
config = Config.instance
|
|
|
|
|
res = config["time_resolution"]
|
|
|
|
|
down = t - (t.to_i % res.minutes)
|
|
|
|
|
up = down + res.minutes
|
|
|
|
|
|
|
|
|
|
diff_down = t - down
|
|
|
|
|
diff_up = up - t
|
|
|
|
|
|
|
|
|
|
diff_down < diff_up ? down : up
|
|
|
|
|
end
|
2014-11-01 21:37:41 +01:00
|
|
|
|
end # class StopTime::Models::TimeEntry
|
2011-11-07 10:24:12 +01:00
|
|
|
|
|
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-07 10:24:12 +01:00
|
|
|
|
class Invoice < Base
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute id [r]
|
|
|
|
|
# @return [Fixnum] unique identification number
|
|
|
|
|
# @!attribute number
|
|
|
|
|
# @return [Fixnum] invoice number
|
|
|
|
|
# @!attribute paid
|
|
|
|
|
# @return [Boolean] flag whether the invoice has been paid
|
|
|
|
|
# @!attribute include_specification
|
|
|
|
|
# @return [Boolean] flag whether the invoice should include a time
|
|
|
|
|
# specification
|
|
|
|
|
# @!attribute created_at
|
|
|
|
|
# @return [Time] time of creation
|
|
|
|
|
# @!attribute updated_at
|
|
|
|
|
# @return [Time] time of last update
|
|
|
|
|
|
|
|
|
|
# @!attribute company_info
|
|
|
|
|
# @return [CompanyInfo] associated company info
|
|
|
|
|
belongs_to :company_info
|
|
|
|
|
# @!attribute customer
|
|
|
|
|
# @return [Customer] associated customer
|
|
|
|
|
belongs_to :customer
|
|
|
|
|
# @!attribute tasks
|
|
|
|
|
# @return [Array<Task>] associated billed tasks
|
2011-11-09 14:02:33 +01:00
|
|
|
|
has_many :tasks
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute time_entries
|
|
|
|
|
# @return [Array<TimeEntry>] associated billed time entries
|
2015-06-20 19:36:30 +02:00
|
|
|
|
has_many :time_entries, through: :tasks
|
2014-11-01 21:37:41 +01:00
|
|
|
|
|
2014-10-18 21:25:26 +02:00
|
|
|
|
default_scope lambda { order('number DESC') }
|
2011-11-07 17:41:46 +01:00
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Returns a time and cost summary for each of the associated tasks.
|
|
|
|
|
# See also {Task#summary} for the specification of the array.
|
|
|
|
|
#
|
|
|
|
|
# @return [Hash{Task=>Array(Float, Float, Float, Float)}] mapping from
|
|
|
|
|
# task to summary
|
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
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Returns a total per VAT rate for the summary of the associated tasks.
|
|
|
|
|
# See also {#summary}.
|
|
|
|
|
#
|
|
|
|
|
# @return [Hash{Float=>Float}] mapping from VAT rate to total for that
|
|
|
|
|
# rate with respect to the summary
|
2012-09-28 11:57:14 +02:00
|
|
|
|
def vat_summary
|
|
|
|
|
vatsumm = Hash.new(0.0)
|
|
|
|
|
summary.each do |task, summ|
|
|
|
|
|
vatsumm[task.vat_rate] += summ[3]
|
|
|
|
|
end
|
|
|
|
|
return vatsumm
|
|
|
|
|
end
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Returns the invoice period based on the associated tasks. This
|
|
|
|
|
# period is a tuple of the earliest of the starting times and the
|
|
|
|
|
# latest of the ending times of all associated tasks.
|
|
|
|
|
# If there are no tasks, the creation time is used.
|
|
|
|
|
#
|
|
|
|
|
# See also {Task#bill_period}.
|
|
|
|
|
#
|
|
|
|
|
# @return [Array(Time, Time)] the invoice period
|
2011-11-09 14:02:33 +01:00
|
|
|
|
def period
|
2014-10-28 10:02:11 +01:00
|
|
|
|
return [created_at, created_at] if tasks.empty?
|
2014-10-18 21:25:51 +02:00
|
|
|
|
|
2014-10-28 10:02:11 +01:00
|
|
|
|
p = [DateTime.now, DateTime.new(0)]
|
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).
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @note VAT will only be applied if the VAT number is given in the
|
|
|
|
|
# associated company information!
|
|
|
|
|
#
|
|
|
|
|
# @return [Float] the total amount (including VAT)
|
2012-01-09 17:38:06 +01:00
|
|
|
|
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).
|
2014-11-01 21:37:41 +01:00
|
|
|
|
#
|
|
|
|
|
# @return [Boolean] whether payment of the invoice is past due
|
2013-06-16 23:29:01 +02:00
|
|
|
|
def past_due?
|
|
|
|
|
not paid? and (Time.now - created_at) > 30.days # FIXME: hardcoded!
|
|
|
|
|
end
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Returns if the invoice is _way_ past due (i.e. it has not been paid
|
|
|
|
|
# within twice the required amount of days).
|
|
|
|
|
#
|
|
|
|
|
# @return [Boolean] whether payment of the invoice is way past due
|
2013-06-16 23:29:01 +02:00
|
|
|
|
def way_past_due?
|
|
|
|
|
past_due? and (Time.now - created_at) > 2 * 30.days
|
|
|
|
|
end
|
2014-11-01 21:37:41 +01:00
|
|
|
|
end # class StopTime::Models::Invoice
|
2011-10-31 14:36:01 +01:00
|
|
|
|
|
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-07 14:54:11 +01:00
|
|
|
|
class CompanyInfo < Base
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute id [r]
|
|
|
|
|
# @return [Fixnum] unique identification number
|
|
|
|
|
# @!attribute name
|
|
|
|
|
# @return [String] official company name
|
|
|
|
|
# @!attribute contact_name
|
|
|
|
|
# @return [String] optional personal contact name
|
|
|
|
|
# @!attribute address_street
|
|
|
|
|
# @return [String] street part of the address
|
|
|
|
|
# @!attribute address_postal_code
|
|
|
|
|
# @return [String] zip/postal code part of the address
|
|
|
|
|
# @!attribute address_city
|
|
|
|
|
# @return [String] city part of the postal code
|
|
|
|
|
# @!attribute country
|
|
|
|
|
# @return [String] country of residence
|
|
|
|
|
# @!attribute country_code
|
|
|
|
|
# @return [String] two letter country code
|
|
|
|
|
# @!attribute email
|
|
|
|
|
# @return [String] email address
|
|
|
|
|
# @!attribute phone
|
|
|
|
|
# @return [String] phone number
|
|
|
|
|
# @!attribute cell
|
|
|
|
|
# @return [String] cellular phone number
|
|
|
|
|
# @!attribute website
|
|
|
|
|
# @return [String] web address
|
|
|
|
|
# @!attribute chamber
|
|
|
|
|
# @return [String] optional chamber of commerce ID number
|
|
|
|
|
# @!attribute vatno
|
|
|
|
|
# @return [String] optional VAT number
|
|
|
|
|
# @!attribute bank_name
|
|
|
|
|
# @return [String] name of the bank
|
|
|
|
|
# @!attribute bank_bic
|
|
|
|
|
# @return [String] bank identification code (or: SWIFT code)
|
|
|
|
|
# @!attribute accountname
|
|
|
|
|
# @return [String] name of the bank account holder
|
|
|
|
|
# @!attribute accountno
|
|
|
|
|
# @return [String] number of the bank account
|
|
|
|
|
# @!attribute accountiban
|
|
|
|
|
# @return [String] international bank account number
|
|
|
|
|
# @!attribute created_at
|
|
|
|
|
# @return [Time] time of creation
|
|
|
|
|
# @!attribute updated_at
|
|
|
|
|
# @return [Time] time of last update
|
|
|
|
|
|
|
|
|
|
# @!attribute invoices
|
|
|
|
|
# @return [Array<Invoice>] associated invoices
|
2012-01-09 15:48:20 +01:00
|
|
|
|
has_many :invoices
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @!attribute original
|
|
|
|
|
# @return [CompanyInfo] original (previous) revision
|
2015-06-20 19:36:30 +02:00
|
|
|
|
belongs_to :original, class_name: "CompanyInfo"
|
2012-01-09 15:48:20 +01:00
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# Returns the revision number.
|
|
|
|
|
# @return [Fixnum] the revision number
|
2012-01-09 15:48:20 +01:00
|
|
|
|
def revision
|
|
|
|
|
id
|
|
|
|
|
end
|
2014-11-01 21:37:41 +01:00
|
|
|
|
end # class StopTime::Models::CompanyInfo
|
2011-11-07 14:54:11 +01:00
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @private
|
|
|
|
|
class StopTimeTables < V 1.0
|
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
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @private
|
|
|
|
|
class CommentSupport < V 1.1
|
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
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @private
|
|
|
|
|
class BilledFlagSupport < V 1.2
|
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
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @private
|
|
|
|
|
class HourlyRateSupport < V 1.3
|
2011-11-03 23:44:06 +01:00
|
|
|
|
def self.up
|
2014-10-18 21:26:14 +02:00
|
|
|
|
config = Config.instance
|
2011-11-03 23:44:06 +01:00
|
|
|
|
add_column(Customer.table_name, :hourly_rate, :float,
|
2015-06-20 19:36:30 +02: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
|
|
|
|
|
|
2014-11-01 21:37:41 +01:00
|
|
|
|
# @private
|
|
|
|
|
class FixedCostTaskSupport < V 1.4
|
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
|
|