stoptime/stoptime.rb

3263 lines
111 KiB
Ruby
Raw Permalink Normal View History

#!usr/bin/env camping
2012-06-06 11:28:59 +02:00
# encoding: UTF-8
#
# 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 version of the application
2018-10-15 19:49:38 +02:00
VERSION = '1.17.1'
puts "Starting Stop… Camping Time! version #{VERSION}"
# @return [Hash{String=>Object}] The parsed configuration.
attr_reader :config
# Overrides 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.
# @return [void]
def self.create
StopTime::Models.create_schema
end
end # module StopTime
# = The Stop… Camping Time! Markaby extensions
module StopTime::Mab
# List of support methods supported by the server.
SUPPORTED = [:get, :post]
# 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
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 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
# 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 # module StopTime::Mab
# = The Stop… Camping Time! models
module StopTime::Models
# == The configuration class
#
# This class contains the application configuration constructed by
# default options (see {DefaultConfig}) merged with the configuration
# found in the configuration file (see {ConfigFile}).
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,
"time_resolution" => 1,
"date_new_entry" => "today",
"vat_rate" => 21.0 }
# Creates a new configuration object and loads the configuation.
# by reading the file +config.yaml+ on disk (see {ConfigFile}, parsing
# it, and performing a merge with the default config (see
# {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
# 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]
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.
class Customer < Base
# @!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
# @return [Boolean] flag whether the customer requires time
# specifications
# @!attribute created_at
# @return [Time] time of creation
# @!attribute updated_at
# @return [Time] time of last update
# @!attribute invoices
# @return [Array<Invoice>] associated invoices
has_many :tasks
# @!attribute tasks
# @return [Array<Task>] associated tasks
has_many :invoices
# @!attribute time_entries
# @return [Array<TimeEntry>] associated time entries
has_many :time_entries, through: :tasks
2011-12-22 16:32:47 +01:00
# Returns the short name if set, otherwise the full name.
#
# @return [String] the shortest name
2011-12-22 16:32:47 +01:00
def shortest_name
short_name.present? ? short_name : name
end
# Returns a list of tasks that have not been billed via in invoice.
#
# @return [Array<Task>] associated unbilled tasks
def unbilled_tasks
tasks.where("invoice_id IS NULL").order("name ASC")
end
# 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.
#
# @return [Array<Task>] associated active tasks
def active_tasks
unbilled_tasks.select do |task|
task.fixed_cost? or task.time_entries.present?
end
end
end # class StopTime::Models::Customer
# == 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.
class Task < Base
# @!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
belongs_to :customer
# @!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
belongs_to :invoice
# Determines whether the task has a fixed cost.
# When +false+ is returned, one can assume the task has an hourly rate.
#
# @return [Boolean] whether the task has a fixed cost
def fixed_cost?
not self.fixed_cost.blank?
end
# Returns the type of the task
#
# @return ["fixed_cose", "hourly_rate"] the type of the task
def type
fixed_cost? ? "fixed_cost" : "hourly_rate"
end
# Returns a list of time entries that should be (and are not yet)
# billed.
#
# @return [Array<TimeEntry>] associated billable time entries
def billable_time_entries
time_entries.where("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.
#
# @return [Array(Time, Time)] the bill period of the task
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.
#
# @return [Boolean] whether the task is billed
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.
#
# @return [Array(Float, Float, Float, Float)] the summary of the task
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.
#
# @return [String] the invoice comment or task name (if not billed)
def comment_or_name
if billed? and self.invoice_comment.present?
self.invoice_comment
else
self.name
end
end
end # class StopTime::Models::Task
# == The time entry class
#
# This class represents an amount of time that is registered on a certain
# task.
class TimeEntry < Base
# @!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
belongs_to :task
# @!attribute customer
# @return [Customer] associated customer
has_one :customer, through: :task
before_validation :round_start_end
# Returns the total amount of time, the duration, in hours (up to
# 2 decimals only!).
#
# @return [Float] the total amount of registered time
def hours_total
((self.end - self.start) / 1.hour).round(2)
end
def in_current_month?
self.end.month == Time.now.month
end
#########
protected
# Rounds the start and end time to the configured resolution using
# {#round_time).
#
# @return [void]
def round_start_end
self.start = round_time(self.start)
self.end = round_time(self.end)
end
#######
private
# Rounds the time using the configured time resolution.
#
# @param [Time] t the input time
# @return [Time] the rounded time
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
end # class StopTime::Models::TimeEntry
# == The invoice class
#
# This class represents an invoice for a customer that contains billed
# tasks and through the tasks registered time.
class Invoice < Base
# @!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
has_many :tasks
# @!attribute time_entries
# @return [Array<TimeEntry>] associated billed time entries
has_many :time_entries, through: :tasks
default_scope lambda { order('number DESC') }
# 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
def summary
summ = {}
tasks.each { |task| summ[task] = task.summary }
return summ
end
# 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
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 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
def period
return [created_at, created_at] if tasks.empty?
p = [DateTime.now, DateTime.new(0)]
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).
#
# @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
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).
#
# @return [Boolean] whether payment of the invoice is past due
def past_due?
not paid? and (Time.now - created_at) > 30.days # FIXME: hardcoded!
end
# 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
def way_past_due?
past_due? and (Time.now - created_at) > 2 * 30.days
end
end # class StopTime::Models::Invoice
# == The company information class
#
# This class contains information about the company or sole
# proprietorship of the user of Stop… Camping Time!
class CompanyInfo < Base
# @!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
has_many :invoices
# @!attribute original
# @return [CompanyInfo] original (previous) revision
belongs_to :original, class_name: "CompanyInfo"
# Returns the revision number.
# @return [Fixnum] the revision number
def revision
id
end
end # class StopTime::Models::CompanyInfo
# @private
class StopTimeTables < V 1.0
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
# @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
# @private
class BilledFlagSupport < V 1.2
def self.up
add_column(TimeEntry.table_name, :bill, :boolean)
end
def self.down
remove_column(TimeEntry.table_name, :bill)
end
end
# @private
class HourlyRateSupport < V 1.3
def self.up
config = Config.instance
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
# @private
class FixedCostTaskSupport < V 1.4
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