diff --git a/.gitignore b/.gitignore index ac13607..ef2007a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ config/database.yml config/config.yml +PocketMoneyDB.sql **/.DS_Store diff --git a/Gemfile b/Gemfile index f58c20f..bd0c390 100644 --- a/Gemfile +++ b/Gemfile @@ -28,7 +28,7 @@ gem 'turbolinks' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder gem 'jbuilder', '~> 1.0.1' - +gem 'draper' gem 'haml' gem 'twitter-bootstrap-rails', :git => 'git://github.com/seyhunak/twitter-bootstrap-rails.git' gem 'less-rails' diff --git a/Gemfile.lock b/Gemfile.lock index cb0d74c..51974e5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -47,6 +47,11 @@ GEM execjs coffee-script-source (1.6.2) commonjs (0.2.6) + draper (1.3.0) + actionpack (>= 3.0) + activemodel (>= 3.0) + activesupport (>= 3.0) + request_store (~> 1.0.3) erubis (2.7.0) execjs (1.4.0) multi_json (~> 1.0) @@ -104,6 +109,7 @@ GEM rdoc (3.12.2) json (~> 1.4) ref (1.0.5) + request_store (1.0.5) ruby-progressbar (1.1.1) sass (3.2.9) sass-rails (4.0.0.rc1) @@ -148,6 +154,7 @@ PLATFORMS DEPENDENCIES coffee-rails (~> 4.0.0) + draper haml jbuilder (~> 1.0.1) jquery-rails diff --git a/app/assets/stylesheets/categories.sass b/app/assets/stylesheets/categories.sass new file mode 100644 index 0000000..c215c0c --- /dev/null +++ b/app/assets/stylesheets/categories.sass @@ -0,0 +1,2 @@ +.budget + width: 700px diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb new file mode 100644 index 0000000..eaa431c --- /dev/null +++ b/app/controllers/categories_controller.rb @@ -0,0 +1,6 @@ +class CategoriesController < ApplicationController + def budgets + @budgets = BudgetCategoriesDecorator.new(Category::BudgetCategory.all) + @filter = Category.filter(params) + end +end diff --git a/app/decorators/budget_categories_decorator.rb b/app/decorators/budget_categories_decorator.rb new file mode 100644 index 0000000..11d84d9 --- /dev/null +++ b/app/decorators/budget_categories_decorator.rb @@ -0,0 +1,3 @@ +class BudgetCategoriesDecorator < Draper::Decorator + delegate_all +end diff --git a/app/decorators/budget_category_decorator.rb b/app/decorators/budget_category_decorator.rb new file mode 100644 index 0000000..061b5b2 --- /dev/null +++ b/app/decorators/budget_category_decorator.rb @@ -0,0 +1,40 @@ +class BudgetCategoryDecorator < Draper::Decorator + delegate_all + + def dates_intervals(from, to) + @@from = from + @@to = to+1 + end + + def amount + amount = Category::BudgetCategory.interval(@@from, @@to) + amount = amount.sum_category(model.id) + amount < 0 ? amount*-1 : amount + end + + def limit + (model.amount_day * ((@@to - @@from).to_i+1)).round(0) + end + + def balance + (limit - amount).round(0) + end + + def available + balance < 0 ? 0 : balance + end + + def color + balance < 0 ? "danger" : "warning" + end + + def spent_percentage + percentage = (amount * 100) / limit + percentage < 100 ? percentage : 100 + end + + def available_percentage + 100 - spent_percentage + end + +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a65b4e1..717067d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -27,4 +27,15 @@ def progress_bar(type, percentage, &block) content_tag :div, :class=> "bar bar-#{type}", :style=>"width:#{percentage}%;", &block if percentage > 0 end + def set_period(filter) + case filter.kind + when "month" + filter.from.strftime("%B %Y") + when "week" + "#{filter.from.strftime('%d %B %Y')} - #{filter.to.strftime('%d %B %Y')}" + when "year" + filter.from.year + end + end + end diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb new file mode 100644 index 0000000..5e35611 --- /dev/null +++ b/app/helpers/categories_helper.rb @@ -0,0 +1,7 @@ +module CategoriesHelper + + def total_budget + Category::BudgetCategory.total_values(@filter.from, @filter.to+1.day) + end + +end diff --git a/app/models/category.rb b/app/models/category.rb index fc6134b..8c13b40 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,8 +1,83 @@ class Category < ActiveRecord::Base + include FilterInterval + class BudgetCategory < ActiveRecord::Base + self.table_name = "budget_categories" + self.primary_key = "id" + belongs_to :category, foreign_key: "id" + scope :incomes, -> { budgets.where(budget_type: 1)} + scope :expenses, -> { budgets.where(budget_type: 0)} + scope :sum_incomes, -> { where(budget_type: 1).sum(:amount) } + scope :sum_expenses, -> { where(budget_type: 0).sum(:amount) } + scope :interval, ->(from, to) { where("budget_categories.date >= ? AND budget_categories.date <= ?", from, to) } + scope :budgets, -> {select("id, amount_day, budget_type, name").order("name").uniq} + scope :sum_category, ->(id) { where(id: id).sum(:amount) } + + def self.total_values(from, to) + @@days = (to - from).to_i + @@interval = interval(from, to) + @@spent = total_spent * -1 + @@entries = total_entries + Hash['saved', saved, 'beat', beat_budget, 'missing', missing_budget, 'deficit', deficit ] + end + + private + def self.total_spent + @@interval.sum_expenses + end + + def self.total_entries + @@interval.sum_incomes + end + + def self.saved + @@entries > @@spent ? @@entries - @@spent : 0 + end + + def self.beat_budget + value = income_vs_expense + value > 0 ? value : 0 + end + + def self.missing_budget + value = income_vs_expense + value < 0 ? value : 0 + end + + def self.deficit + @@entries - @@spent > 0 ? 0 : @@entries - @@spent + end + + def self.income_vs_expense + incomes = @@entries - budget_entries + expenses = budget_expenses - @@spent + incomes + expenses + end + + def self.budget_entries + ((incomes.map(&:amount_day).sum) * @@days).round(0) + end + + def self.budget_expenses + ((expenses.map(&:amount_day).sum) * @@days).round(0) + end + end + + ## Budget period + # 0 diario + # 1 Semanal + # 2 Mensual + # 3 Cuatrimestral + # 4 Anual scope :active, -> { where(deleted:false) } scope :transaction_ids, ->(ids) { joins(:splits => :transaction).where(transactions: {id: ids}) } scope :transaction_totals, -> { joins(:splits => :transaction).merge(Transaction.total_amount) } scope :group_by_name, -> { select('categories.name', 'categories.id').group('categories.name', 'categories.id') } has_many :splits + has_one :budget_category, foreign_key: "id" + + def self.filter(params) + Filter.new(params) + end + end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index ee2cc2b..e0ed4fe 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -1,4 +1,5 @@ class Transaction < ActiveRecord::Base + include FilterInterval # pm_types # 0 Withdrawal # 1 Deposit @@ -17,14 +18,12 @@ class Transaction < ActiveRecord::Base scope :balance, -> { select('*').select('SUM(amount) OVER (PARTITION BY account_id ORDER BY date ASC) as balance') } scope :transaction_includes, -> { includes(:account,:splits => :category) } scope :full, -> { order_date.transaction_includes.active.balance } - scope :interval, ->(from, to) { where("transactions.date >= ? AND transactions.date <= ?", from, to) } scope :total_amount, -> { select('count(transactions.amount) as total_count', 'sum(transactions.amount) as total_amount') } - - belongs_to :account - has_many :splits - - + + belongs_to :account + has_many :splits + def split? @split ||= splits.size > 1 end @@ -43,183 +42,4 @@ def self.filter(conditions) Filter.new(conditions) end - class Filter - - def initialize(conditions) - @conditions = conditions - @category_ids = [] - end - - def accounts - @accounts ||= Account.all - end - - def transactions - @transactions ||= transaction_query.full - end - - def grouped_transactions - case group_by - when 'date' - transactions.group_by {|t| t.date.to_s(:short_date) } - when 'type' - transactions.group_by {|t| t.type_name } - when 'account' - transactions.group_by {|t| t.account.name } - when 'category' - transactions.group_by {|t| t.category_name.to_s } - end - end - - def categories - @categories ||= Category.transaction_totals.group_by_name.transaction_ids(transaction_ids) - end - - def intervals - [ Interval.new(:week), Interval.new(:month), Interval.new(:year) ] - end - - def current_interval - @conditions[:from] ? Interval.new(kind,from) : intervals.select {|i| i.kind.to_s == kind }.first - end - - def from - @conditions[:from] ? Date.parse(@conditions[:from]) : current_interval.from - end - - def to - @conditions[:to] ? Date.parse(@conditions[:to]) : current_interval.to - end - - def kind - @conditions[:kind] || 'month' - end - - def previous - @previous ||= current_interval.previous - end - - def next - @next ||= current_interval.next - end - - def categories_total - categories.to_a.sum(&:total_amount) - end - - def types_total - @types_total = widthdrawals_amount + deposits_amount + transfers_amount - end - - def widthdrawals_amount - types_query[0].to_f.abs - end - - def deposits_amount - types_query[1].to_f.abs - end - - def transfers_amount - types_query[2].to_f.abs - end - - def widthdrawals_percentage - (widthdrawals_amount/types_total * 100) - end - - def deposits_percentage - (deposits_amount / types_total * 100) - end - - def transfers_percentage - (transfers_amount / types_total * 100) - end - - def transaction_ids - @transaction_ids ||= transactions.map &:id - end - - - private - - def types_query - @types_query ||= Transaction.where(id: transaction_ids ).group('transactions.pm_type').sum(:amount) - end - - def transaction_interval - Transaction.interval(from, to) - end - - def transaction_query - t = transaction_interval - t = t.where(pm_type: pm_type) if pm_type - t = t.where(account_id: account_id) if account_id - t = t.where('categories.id = ?', category_id) if category_id - t - end - - def account_id - @conditions[:account_id] - end - - def category_id - @conditions[:category_id] - end - - def pm_type - @conditions[:pm_type] - end - - def group_by - @conditions[:group_by] || 'date' - end - - class Interval - attr_accessor :from, :to, :kind - - def initialize(kind, date = nil) - @kind = kind - @date = date - end - - def from - date.send("beginning_of_#{kind}") - end - - def to - date.send("end_of_#{kind}") - end - - def date - @date || Date.today - end - - def to_hash - { kind: kind, from: from_string, to: to_string } - end - - def from_string - from.strftime('%Y%m%d') - end - - def to_string - to.strftime('%Y%m%d') - end - - def name - kind.to_s.humanize - end - - def previous - Interval.new(kind, from - 1.send(kind)) - end - - def next - Interval.new(kind, from + 1.send(kind)) - end - - end - - end - end diff --git a/app/views/categories/_actual_budgets.haml b/app/views/categories/_actual_budgets.haml new file mode 100644 index 0000000..d01a8d8 --- /dev/null +++ b/app/views/categories/_actual_budgets.haml @@ -0,0 +1,40 @@ +%table.table-bordered + %thead + %tr + %th Categories + %th + =set_period(@filter) + %th + %tr + %th + %th Entries + %th Budgeted + %th Remaining + =render 'incomes_table' + %thead + %tr + %th + %th Expenses + %th Budgeted + %th Available + %th Balance + =render 'expenses_table' +%table.table-bordered + %thead + %tr + %th Saved + %th Beat Budget + %th Missed Budget + %th Deficit + %tbody.summary-header + -result = total_budget + %tr + %th + =result['saved'] + %th + =result['beat'] + %th + =result['missing'] + %th + =result['deficit'] + diff --git a/app/views/categories/_expenses_table.haml b/app/views/categories/_expenses_table.haml new file mode 100644 index 0000000..9df675d --- /dev/null +++ b/app/views/categories/_expenses_table.haml @@ -0,0 +1,20 @@ +%tbody.summary-header.progress + -@budgets.expenses.each do |bud| + -budget = BudgetCategoryDecorator.decorate(bud) + -budget.dates_intervals(@filter.from, @filter.to) + %tr + %td + =budget.name + %td.budget + =progress_bar budget.color, budget.spent_percentage do + %p + =budget.amount + =progress_bar "success", budget.available_percentage do + %p + =budget.available + %td + =budget.limit + %td + =budget.available + %td + =budget.balance diff --git a/app/views/categories/_filters.haml b/app/views/categories/_filters.haml new file mode 100644 index 0000000..259446b --- /dev/null +++ b/app/views/categories/_filters.haml @@ -0,0 +1,17 @@ +%ul.nav.nav-list.left.filters + %li + %li.icon-backward + = link_to url_for params.merge(@filter.previous.to_hash) do + Previous + = @filter.previous.name + %li + %li.icon-forward + = link_to url_for params.merge(@filter.next.to_hash) do + Next + = @filter.next.name + + %li.divider + + =render "intervals" + + %li.divider diff --git a/app/views/categories/_incomes_table.haml b/app/views/categories/_incomes_table.haml new file mode 100644 index 0000000..4848dc1 --- /dev/null +++ b/app/views/categories/_incomes_table.haml @@ -0,0 +1,19 @@ +%tbody.summary-header.progress + -@budgets.incomes.each do |bud| + -budget = BudgetCategoryDecorator.decorate(bud) + -budget.dates_intervals(@filter.from, @filter.to) + %tr + %td + =budget.name + %td.budget + =progress_bar "success", budget.spent_percentage do + %p + =budget.amount + =progress_bar "danger", budget.available_percentage do + %p + =budget.available + %td + =budget.limit + %td + =budget.available + %td diff --git a/app/views/categories/_intervals.html.haml b/app/views/categories/_intervals.html.haml new file mode 100644 index 0000000..e12d789 --- /dev/null +++ b/app/views/categories/_intervals.html.haml @@ -0,0 +1,5 @@ +- @filter.intervals.each do |interval| + = content_tag :li, :class=> params[:kind] == interval.kind.to_s ? 'active' : '' do + = link_to url_for params.merge(interval.to_hash) do + This + = interval.name diff --git a/app/views/categories/budgets.haml b/app/views/categories/budgets.haml new file mode 100644 index 0000000..76c146c --- /dev/null +++ b/app/views/categories/budgets.haml @@ -0,0 +1,7 @@ +.content + %h1 Budgets + + .span3 + =render partial: 'filters' + .span8 + = render partial: 'actual_budgets' diff --git a/app/views/layouts/_navigation.haml b/app/views/layouts/_navigation.haml index 2006527..2d17d5c 100644 --- a/app/views/layouts/_navigation.haml +++ b/app/views/layouts/_navigation.haml @@ -8,6 +8,7 @@ %ul.nav %li= link_to 'All transactions', transactions_path = render '/accounts/menu' + %li=link_to 'Budgets', budgets_path %li= link_to 'Import', import_index_path diff --git a/app/views/layouts/application.haml b/app/views/layouts/application.haml index 3731878..1725ec4 100644 --- a/app/views/layouts/application.haml +++ b/app/views/layouts/application.haml @@ -1,11 +1,11 @@ !!! %html %head - %meta{:charset => "utf-8"} - %meta{:name => "viewport", :content => "width=device-width, initial-scale=1, maximum-scale=1"} + %meta{charset: "utf-8"} + %meta{name: "viewport", :content => "width=device-width, initial-scale=1, maximum-scale=1"} %title= content_for?(:title) ? yield(:title) : "Bernard" - %meta{:content => "", :name => "description"} - %meta{:content => "", :name => "author"} + %meta{content: "", name: "description"} + %meta{content: "", name: "author"} = stylesheet_link_tag "application", media: "all", "data-turbolinks-track" => true = javascript_include_tag "application", "data-turbolinks-track" => true = csrf_meta_tags diff --git a/config/initializers/filter.rb b/config/initializers/filter.rb new file mode 100644 index 0000000..4ceeaa0 --- /dev/null +++ b/config/initializers/filter.rb @@ -0,0 +1 @@ +require 'modules/module_filter' diff --git a/config/routes.rb b/config/routes.rb index 51066a7..5806e37 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,7 @@ resources :import do post 'import_csv', on: :collection end + get 'budgets/' => 'categories#budgets' # The priority is based upon order of creation: first created -> highest priority. # See how all your routes lay out with "rake routes". diff --git a/db/migrate/20131115215846_create_budget_categories.rb b/db/migrate/20131115215846_create_budget_categories.rb new file mode 100644 index 0000000..e52bfba --- /dev/null +++ b/db/migrate/20131115215846_create_budget_categories.rb @@ -0,0 +1,25 @@ +class CreateBudgetCategories < ActiveRecord::Migration + def change + execute <<-SQL + CREATE VIEW budget_categories AS + SELECT name,c.id as id, + t.id as transaction_id, + c.budget_limit as limit, + c.budget_period as period, + t.date, + c.pm_type as budget_type, + t.amount, + CASE + when budget_period = 1 then budget_limit/7 + when budget_period = 2 then budget_limit/30 + when budget_period = 4 then budget_limit/365 + end as amount_day, + (SELECT sum(amount) from transactions t where t.category_id = c.id + and t.deleted = 'false' and t.pm_type <> 5) as category_amount + FROM categories c + inner join transactions t on t.category_id = c.id and t.deleted = false and t.pm_type <> 5 + WHERE c.budget_limit is not null and c.deleted = 'false' + ORDER BY c.name + SQL + end +end diff --git a/db/schema.rb b/db/schema.rb index af83078..a7e297c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,10 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20130718222102) do - - # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" +ActiveRecord::Schema.define(version: 20131115215846) do create_table "accounts", force: true do |t| t.boolean "deleted" diff --git a/lib/modules/module_filter.rb b/lib/modules/module_filter.rb new file mode 100644 index 0000000..eb24c5e --- /dev/null +++ b/lib/modules/module_filter.rb @@ -0,0 +1,180 @@ +module FilterInterval + class Filter + + def initialize(conditions) + @conditions = conditions + @category_ids = [] + end + + def accounts + @accounts ||= Account.all + end + + def transactions + @transactions ||= transaction_query.full + end + + def grouped_transactions + case group_by + when 'date' + transactions.group_by {|t| t.date.to_s(:short_date) } + when 'type' + transactions.group_by {|t| t.type_name } + when 'account' + transactions.group_by {|t| t.account.name } + when 'category' + transactions.group_by {|t| t.category_name.to_s } + end + end + + def categories + @categories ||= Category.transaction_totals.group_by_name.transaction_ids(transaction_ids) + end + + def intervals + [ Interval.new(:week), Interval.new(:month), Interval.new(:year) ] + end + + def current_interval + @conditions[:from] ? Interval.new(kind,from) : intervals.select {|i| i.kind.to_s == kind }.first + end + + def from + @conditions[:from] ? Date.parse(@conditions[:from]) : current_interval.from + end + + def to + @conditions[:to] ? Date.parse(@conditions[:to]) : current_interval.to + end + + def kind + @conditions[:kind] || 'month' + end + + def previous + @previous ||= current_interval.previous + end + + def next + @next ||= current_interval.next + end + + def categories_total + categories.to_a.sum(&:total_amount) + end + + def types_total + @types_total = widthdrawals_amount + deposits_amount + transfers_amount + end + + def widthdrawals_amount + types_query[0].to_f.abs + end + + def deposits_amount + types_query[1].to_f.abs + end + + def transfers_amount + types_query[2].to_f.abs + end + + def widthdrawals_percentage + (widthdrawals_amount/types_total * 100) + end + + def deposits_percentage + (deposits_amount / types_total * 100) + end + + def transfers_percentage + (transfers_amount / types_total * 100) + end + + def transaction_ids + @transaction_ids ||= transactions.map &:id + end + + + private + + def types_query + @types_query ||= Transaction.where(id: transaction_ids ).group('transactions.pm_type').sum(:amount) + end + + def transaction_interval + Transaction.interval(from, to) + end + + def transaction_query + t = transaction_interval + t = t.where(pm_type: pm_type) if pm_type + t = t.where(account_id: account_id) if account_id + t = t.where('categories.id = ?', category_id) if category_id + t + end + + def account_id + @conditions[:account_id] + end + + def category_id + @conditions[:category_id] + end + + def pm_type + @conditions[:pm_type] + end + + def group_by + @conditions[:group_by] || 'date' + end + + class Interval + attr_accessor :from, :to, :kind + + def initialize(kind, date = nil) + @kind = kind + @date = date + end + + def from + date.send("beginning_of_#{kind}") + end + + def to + date.send("end_of_#{kind}") + end + + def date + @date || Date.today + end + + def to_hash + { kind: kind, from: from_string, to: to_string } + end + + def from_string + from.strftime('%Y%m%d') + end + + def to_string + to.strftime('%Y%m%d') + end + + def name + kind.to_s.humanize + end + + def previous + Interval.new(kind, from - 1.send(kind)) + end + + def next + Interval.new(kind, from + 1.send(kind)) + end + + end + end +end + diff --git a/lib/pocket_money.rb b/lib/pocket_money.rb index cb8bbf9..026a7c3 100644 --- a/lib/pocket_money.rb +++ b/lib/pocket_money.rb @@ -1,3 +1,4 @@ require 'pocket_money/base.rb' require 'pocket_money/tables' require 'pocket_money/import' +require 'pocket_money/categories' diff --git a/lib/pocket_money/categories.rb b/lib/pocket_money/categories.rb new file mode 100644 index 0000000..8017e83 --- /dev/null +++ b/lib/pocket_money/categories.rb @@ -0,0 +1,9 @@ +class AddCategories + def self.categories + transactions = Transaction.all + transactions.each do |t| + t.category_id = t.splits.first.category_id + t.save + end + end +end diff --git a/lib/tasks/pm.rake b/lib/tasks/pm.rake index a0c6a19..2600712 100644 --- a/lib/tasks/pm.rake +++ b/lib/tasks/pm.rake @@ -2,5 +2,7 @@ namespace :pm do desc 'import pocket money database' task :import => :environment do PocketMoney.import + AddCategories.categories end + end