├── public ├── favicon.ico ├── blank_iframe.html ├── images │ └── calendar_date_select ├── javascripts │ ├── calendar_date_select │ ├── application.js │ ├── accounts.js │ ├── tags.js │ ├── buckets.js │ ├── money.js │ └── statements.js ├── stylesheets │ └── calendar_date_select ├── robots.txt ├── 422.html ├── 404.html ├── 500.html └── mock │ └── summary.html ├── vendor ├── .gitignore └── plugins │ ├── cached_externals │ ├── .gitignore │ ├── script │ │ └── git-hooks │ │ │ ├── post-merge │ │ │ └── post-checkout │ ├── tasks │ │ └── git.rake │ ├── LICENSE │ ├── recipes │ │ └── cached_externals.rb │ └── README.rdoc │ └── .gitignore ├── .gitignore ├── app ├── helpers │ ├── sessions_helper.rb │ ├── tagged_items_helper.rb │ ├── tags_helper.rb │ ├── statements_helper.rb │ ├── buckets_helper.rb │ ├── accounts_helper.rb │ ├── application_helper.rb │ └── subscriptions_helper.rb ├── views │ ├── tags │ │ ├── update.js.rjs │ │ ├── _cloud.html.haml │ │ ├── _name.html.haml │ │ ├── show.html.haml │ │ └── _delete_form.html.haml │ ├── events │ │ ├── show.js.rjs │ │ ├── _event.html.haml │ │ ├── update.js.rjs │ │ ├── edit.html.haml │ │ ├── _reallocation_item.html.haml │ │ ├── new.html.haml │ │ ├── _line_item.html.haml │ │ ├── _balance.html.haml │ │ ├── _tagged_item.html.haml │ │ ├── _expand.html.haml │ │ ├── create.js.rjs │ │ ├── _form_reallocate.html.haml │ │ ├── destroy.js.rjs │ │ ├── _row.html.haml │ │ ├── _form.html.haml │ │ ├── _form_tags.html.haml │ │ ├── _form_general.html.haml │ │ └── _form_section.html.haml │ ├── accounts │ │ ├── new.html.haml │ │ ├── update.js.rjs │ │ ├── _account.html.haml │ │ ├── show.html.haml │ │ ├── _name.html.haml │ │ └── _form.html.haml │ ├── line_items │ │ └── _line_item.html.haml │ ├── tagged_items │ │ └── _tagged_item.html.haml │ ├── account_items │ │ └── _account_item.html.haml │ ├── subscriptions │ │ ├── index.html.haml │ │ ├── _blank_slate.html.haml │ │ └── show.html.haml │ ├── statements │ │ ├── _subtotal.html.haml │ │ ├── show.html.haml │ │ ├── index.html.haml │ │ ├── _uncleared.html.haml │ │ ├── new.html.haml │ │ └── edit.html.haml │ ├── buckets │ │ ├── update.js.rjs │ │ ├── _bucket_name_for_perma.html.haml │ │ ├── _bucket.html.haml │ │ ├── index.html.haml │ │ ├── _bucket_name_for_index.html.haml │ │ ├── _delete_form.html.haml │ │ └── show.html.haml │ ├── sessions │ │ └── new.html.haml │ └── layouts │ │ └── application.html.haml ├── models │ ├── user_subscription.rb │ ├── tag.rb │ ├── user.rb │ ├── bucket.rb │ ├── tagged_item.rb │ ├── line_item.rb │ ├── subscription.rb │ ├── account_item.rb │ ├── statement.rb │ └── account.rb ├── concerns │ ├── categorized_items.rb │ ├── pageable.rb │ └── option_handler.rb └── controllers │ ├── subscriptions_controller.rb │ ├── sessions_controller.rb │ ├── tagged_items_controller.rb │ ├── statements_controller.rb │ ├── application_controller.rb │ ├── accounts_controller.rb │ ├── tags_controller.rb │ ├── buckets_controller.rb │ └── events_controller.rb ├── LICENSE ├── Capfile ├── script ├── about ├── plugin ├── runner ├── server ├── console ├── destroy ├── generate ├── performance │ ├── profiler │ ├── request │ └── benchmarker └── find ├── test ├── fixtures │ ├── subscriptions.yml │ ├── user_subscriptions.yml │ ├── tagged_items.yml │ ├── users.yml │ ├── tags.yml │ ├── statements.yml │ ├── accounts.yml │ ├── account_items.yml │ ├── buckets.yml │ ├── events.yml │ └── line_items.yml ├── unit │ ├── tagged_item_test.rb │ ├── account_item_test.rb │ ├── line_item_test.rb │ ├── tag_test.rb │ ├── user_test.rb │ ├── bucket_test.rb │ ├── statement_test.rb │ └── account_test.rb ├── test_helper.rb └── functional │ ├── sessions_controller_test.rb │ ├── subscriptions_controller_test.rb │ ├── tagged_items_controller_test.rb │ ├── statements_controller_test.rb │ ├── buckets_controller_test.rb │ ├── accounts_controller_test.rb │ └── tags_controller_test.rb ├── config ├── initializers │ ├── calendar_date_select.rb │ ├── activerecord.rb │ ├── redirect_log.rb │ └── new_rails_defaults.rb ├── session.yml ├── deploy.rb ├── routes.rb ├── database.yml ├── environment.rb ├── externals.yml ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb └── boot.rb ├── lib ├── bucket_wise │ └── version.rb └── tasks │ ├── subscription.rake │ └── user.rake ├── db ├── migrate │ ├── 20090330132556_add_memo_field_to_event.rb │ ├── 20090404154634_cache_balances_on_buckets_and_accounts.rb │ ├── 20090421221109_add_statements.rb │ └── 20080513032848_initial_schema.rb └── schema.rb ├── CHANGELOG.rdoc ├── Rakefile ├── TODO └── README.rdoc /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.gitignore: -------------------------------------------------------------------------------- 1 | rails 2 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | log 2 | tmp 3 | db/*.sqlite3 4 | db/backup 5 | *.swp 6 | -------------------------------------------------------------------------------- /app/helpers/sessions_helper.rb: -------------------------------------------------------------------------------- 1 | module SessionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/tagged_items_helper.rb: -------------------------------------------------------------------------------- 1 | module TaggedItemsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/views/tags/update.js.rjs: -------------------------------------------------------------------------------- 1 | page[:name].replace_html :partial => "tags/name" 2 | -------------------------------------------------------------------------------- /public/blank_iframe.html: -------------------------------------------------------------------------------- 1 | ../vendor/plugins/calendar_date_select/public/blank_iframe.html -------------------------------------------------------------------------------- /vendor/plugins/.gitignore: -------------------------------------------------------------------------------- 1 | calendar_date_select 2 | haml 3 | project_search 4 | safe_mass_assignment 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is hereby placed in the public domain. 2 | - Jamis Buck (author), April 2009 3 | -------------------------------------------------------------------------------- /public/images/calendar_date_select: -------------------------------------------------------------------------------- 1 | ../../vendor/plugins/calendar_date_select/public/images/calendar_date_select -------------------------------------------------------------------------------- /Capfile: -------------------------------------------------------------------------------- 1 | load 'deploy' 2 | Dir['vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) } 3 | load 'config/deploy' 4 | -------------------------------------------------------------------------------- /public/javascripts/calendar_date_select: -------------------------------------------------------------------------------- 1 | ../../vendor/plugins/calendar_date_select/public/javascripts/calendar_date_select -------------------------------------------------------------------------------- /public/stylesheets/calendar_date_select: -------------------------------------------------------------------------------- 1 | ../../vendor/plugins/calendar_date_select/public/stylesheets/calendar_date_select -------------------------------------------------------------------------------- /script/about: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/about' 4 | -------------------------------------------------------------------------------- /script/plugin: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/plugin' 4 | -------------------------------------------------------------------------------- /script/runner: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/runner' 4 | -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/server' 4 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/console' 4 | -------------------------------------------------------------------------------- /script/destroy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/destroy' 4 | -------------------------------------------------------------------------------- /script/generate: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/boot' 3 | require 'commands/generate' 4 | -------------------------------------------------------------------------------- /test/fixtures/subscriptions.yml: -------------------------------------------------------------------------------- 1 | john: 2 | owner: john 3 | 4 | tim: 5 | owner: tim 6 | 7 | john_family: 8 | owner: john 9 | -------------------------------------------------------------------------------- /app/models/user_subscription.rb: -------------------------------------------------------------------------------- 1 | class UserSubscription < ActiveRecord::Base 2 | belongs_to :subscription 3 | belongs_to :user 4 | end -------------------------------------------------------------------------------- /app/views/events/show.js.rjs: -------------------------------------------------------------------------------- 1 | page.events.expanded(event.id) 2 | page.insert_html :after, dom_id(event), :partial => "events/expand" 3 | -------------------------------------------------------------------------------- /app/views/tags/_cloud.html.haml: -------------------------------------------------------------------------------- 1 | #cloud 2 | - subscription.tags.sort_by(&:name).each do |item| 3 | = link_to(item.name, tag_path(item)) 4 | -------------------------------------------------------------------------------- /app/views/events/_event.html.haml: -------------------------------------------------------------------------------- 1 | = render :partial => "events/row", :locals => { :event => event, :amount => event.balance, :account_links => true } 2 | -------------------------------------------------------------------------------- /script/performance/profiler: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/performance/profiler' 4 | -------------------------------------------------------------------------------- /script/performance/request: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/performance/request' 4 | -------------------------------------------------------------------------------- /script/performance/benchmarker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../../config/boot' 3 | require 'commands/performance/benchmarker' 4 | -------------------------------------------------------------------------------- /config/initializers/calendar_date_select.rb: -------------------------------------------------------------------------------- 1 | CalendarDateSelect.format = :iso_date 2 | CalendarDateSelect.default_options[:year_range] = 10.years.ago.to_date..Date.today 3 | -------------------------------------------------------------------------------- /app/views/accounts/new.html.haml: -------------------------------------------------------------------------------- 1 | :javascript 2 | Accounts.origin = #{subscription_url(subscription).to_json}; 3 | 4 | #new_account.form= render :partial => "accounts/form" 5 | -------------------------------------------------------------------------------- /app/views/line_items/_line_item.html.haml: -------------------------------------------------------------------------------- 1 | = render :partial => "events/row", :locals => { :event => line_item.event, :amount => line_item.amount, :account_links => false } 2 | -------------------------------------------------------------------------------- /app/helpers/tags_helper.rb: -------------------------------------------------------------------------------- 1 | module TagsHelper 2 | def possible_receiver_tags 3 | @possible_receiver_tags ||= (subscription.tags - [tag_ref]).sort_by(&:name) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/tagged_items/_tagged_item.html.haml: -------------------------------------------------------------------------------- 1 | = render :partial => "events/row", :locals => { :event => tagged_item.event, :amount => tagged_item.amount, :account_links => true } 2 | -------------------------------------------------------------------------------- /app/views/account_items/_account_item.html.haml: -------------------------------------------------------------------------------- 1 | = render :partial => "events/row", :locals => { :event => account_item.event, :amount => account_item.amount, :account_links => false } 2 | -------------------------------------------------------------------------------- /public/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // Place your application-specific JavaScript functions and classes here 2 | // This file is automatically included by javascript_include_tag :defaults 3 | -------------------------------------------------------------------------------- /config/session.yml: -------------------------------------------------------------------------------- 1 | :session_key: _bucketwise_session 2 | :secret: "2f8e38ad3db3c9c3be9e60866b6d9137919c9ffb96431d6c0620217fe99fae08b1860b13a133dccd953192cc6b3dfb232031a00a6baa6cf7ce70edde39db5b09" 3 | -------------------------------------------------------------------------------- /lib/bucket_wise/version.rb: -------------------------------------------------------------------------------- 1 | module BucketWise 2 | module Version 3 | MAJOR = 1 4 | MINOR = 0 5 | TINY = 0 6 | 7 | STRING = [MAJOR, MINOR, TINY].join(".") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/helpers/statements_helper.rb: -------------------------------------------------------------------------------- 1 | module StatementsHelper 2 | def uncleared_row_class(item) 3 | classes = [cycle('odd', 'even')] 4 | classes << "cleared" if item.statement_id 5 | classes.join(" ") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/events/update.js.rjs: -------------------------------------------------------------------------------- 1 | if !event.valid? 2 | errors = event.errors.full_messages.join("\n") 3 | page.alert("This transaction could not be saved:\n\n" + errors) 4 | 5 | else 6 | page.events.return_to_caller 7 | end 8 | -------------------------------------------------------------------------------- /script/find: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | Dir.chdir(File.join(File.dirname(__FILE__), "..")) do 3 | $LOAD_PATH.unshift "vendor/plugins/project_search/lib" 4 | require 'project_search' 5 | 6 | ProjectSearch.new(ARGV).search 7 | end 8 | -------------------------------------------------------------------------------- /app/helpers/buckets_helper.rb: -------------------------------------------------------------------------------- 1 | module BucketsHelper 2 | def source_view 3 | params[:view] || "index" 4 | end 5 | 6 | def possible_receiver_buckets 7 | (account.buckets - [bucket]).sort_by(&:name) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-Agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/script/git-hooks/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -d vendor/plugins/cached_externals ]; 4 | then 5 | cap -q -f vendor/plugins/cached_externals/recipes/cached_externals.rb local externals:setup 6 | fi; 7 | -------------------------------------------------------------------------------- /app/views/subscriptions/index.html.haml: -------------------------------------------------------------------------------- 1 | #data.content 2 | %h2 Your Subscriptions 3 | 4 | %ul 5 | - user.subscriptions.each do |subscription| 6 | %li{:id => dom_id(subscription)}= link_to(subscription.id, subscription_path(subscription)) 7 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/script/git-hooks/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ "1" == $3 && -d vendor/plugins/cached_externals ]]; 4 | then 5 | cap -q -f vendor/plugins/cached_externals/recipes/cached_externals.rb local externals:setup 6 | fi; 7 | -------------------------------------------------------------------------------- /config/initializers/activerecord.rb: -------------------------------------------------------------------------------- 1 | module Pingalingaling 2 | def ping 3 | self.updated_at = Time.now.utc 4 | end 5 | 6 | def ping! 7 | ping 8 | save! 9 | end 10 | end 11 | 12 | ActiveRecord::Base.send(:include, Pingalingaling) 13 | -------------------------------------------------------------------------------- /db/migrate/20090330132556_add_memo_field_to_event.rb: -------------------------------------------------------------------------------- 1 | class AddMemoFieldToEvent < ActiveRecord::Migration 2 | def self.up 3 | add_column :events, :memo, :text 4 | end 5 | 6 | def self.down 7 | remove_column :events, :memo 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/events/edit.html.haml: -------------------------------------------------------------------------------- 1 | :javascript 2 | Events.return_to = #{(params[:return_to] || subscription_path(subscription)).to_json}; 3 | 4 | #data.content 5 | %h2 Edit transaction 6 | 7 | .transaction.edit.form{:class => @event.role.to_s}= render :partial => "events/form" 8 | -------------------------------------------------------------------------------- /app/views/tags/_name.html.haml: -------------------------------------------------------------------------------- 1 | %span.actions== #{link_to_function("Rename", "Tags.rename(#{tag_path(tag_ref).to_json}, #{tag_ref.name.to_json}, #{form_authenticity_token.to_json})")} | #{link_to_function("Delete", "Tags.deleteTag()")} 2 | == Transactions tagged "#{h(tag_ref.name)}" 3 | -------------------------------------------------------------------------------- /config/deploy.rb: -------------------------------------------------------------------------------- 1 | # FIXME: common deployment stuff here, with a conditional load of the real 2 | # recipe file, so that the real stuff can exist locally without being 3 | # checked into the main repo. 4 | 5 | capfile = File.expand_path("~/.bucketwise/Capfile") 6 | load(capfile) if File.exists?(capfile) 7 | -------------------------------------------------------------------------------- /app/views/events/_reallocation_item.html.haml: -------------------------------------------------------------------------------- 1 | Reallocate 2 | = "$" + text_field_tag("amount", line_item_amount_value(reallocation_item), :class => "number", :size => 9) 3 | == #{reallocation_verbs_for(section).last} 4 | = select_bucket(section, :splittable => false, :line_item => reallocation_item) 5 | -------------------------------------------------------------------------------- /app/views/statements/_subtotal.html.haml: -------------------------------------------------------------------------------- 1 | .subtotal 2 | .settled 3 | Subtotal: 4 | %span.subtotal_dollars= format_cents(subtotal) 5 | .remaining 6 | Remaining: 7 | %span.remaining_dollars{:class => statement.unsettled_balance.zero? ? "balanced" : nil} 8 | = format_cents(statement.unsettled_balance) 9 | -------------------------------------------------------------------------------- /app/views/buckets/update.js.rjs: -------------------------------------------------------------------------------- 1 | if bucket.valid? 2 | page[dom_id(bucket, :name)].replace_html :partial => "buckets/bucket_name_for_#{source_view}", 3 | :locals => { :bucket => bucket } 4 | else 5 | errors = bucket.errors.full_messages.join("\n") 6 | page.alert("The bucket could not be updated:\n\n" + errors); 7 | end 8 | -------------------------------------------------------------------------------- /CHANGELOG.rdoc: -------------------------------------------------------------------------------- 1 | === (new) 2 | 3 | * filter password from logs [Jordan Brough] 4 | 5 | * fix incorrect rails gem reference in config/environment.rb [Jamis Buck] 6 | 7 | * fix reference to ~/.bucketwise/Capfile so it only loads if the file exists [Jamis Buck] 8 | 9 | 10 | === 1.0.0 / 20 Apr 2009 11 | 12 | * First release 13 | -------------------------------------------------------------------------------- /app/views/buckets/_bucket_name_for_perma.html.haml: -------------------------------------------------------------------------------- 1 | %span.actions== #{link_to_function("Rename", "Buckets.rename(#{bucket_path(bucket).to_json}, #{bucket.name.to_json}, #{form_authenticity_token.to_json})")} | #{link_to_function("Delete/Merge", "Buckets.deleteBucket()")} 2 | == #{link_to(h(bucket.account.name), bucket.account)}: 3 | &= bucket.name 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require(File.join(File.dirname(__FILE__), 'config', 'boot')) 5 | 6 | require 'rake' 7 | require 'rake/testtask' 8 | require 'rake/rdoctask' 9 | 10 | require 'tasks/rails' 11 | -------------------------------------------------------------------------------- /app/views/buckets/_bucket.html.haml: -------------------------------------------------------------------------------- 1 | %tr.bucket{:id => dom_id(bucket), :class => cycle('odd', 'even'), :onmouseover => "Buckets.onMouseOver(#{bucket.id})", :onmouseout => "Buckets.onMouseOut(#{bucket.id})"} 2 | 3 | %td{:id => dom_id(bucket, :name)} 4 | = render :partial => "buckets/bucket_name_for_index", :locals => { :bucket => bucket } 5 | 6 | = balance_cell(bucket) 7 | -------------------------------------------------------------------------------- /test/fixtures/user_subscriptions.yml: -------------------------------------------------------------------------------- 1 | john: 2 | subscription: john 3 | user: john 4 | created_at: <%= 60.days.ago.utc.to_s(:db) %> 5 | 6 | tim: 7 | subscription: tim 8 | user: tim 9 | created_at: <%= 59.days.ago.utc.to_s(:db) %> 10 | 11 | john_family: 12 | subscription: john_family 13 | user: john 14 | created_at: <%= 55.days.ago.utc.to_s(:db) %> 15 | -------------------------------------------------------------------------------- /app/views/events/new.html.haml: -------------------------------------------------------------------------------- 1 | :javascript 2 | Events.return_to = #{(params[:return_to] || subscription_path(subscription)).to_json}; 3 | Events.defaultActor = "Bucket reallocation"; 4 | Events.defaultDate = #{Date.today.to_json}; 5 | 6 | #data.content 7 | %h2 New transaction 8 | 9 | .transaction.form{:class => @event.role.to_s}= render :partial => "events/form" 10 | -------------------------------------------------------------------------------- /app/views/accounts/update.js.rjs: -------------------------------------------------------------------------------- 1 | if account.valid? 2 | page[dom_id(account, :name)].replace_html :partial => "accounts/name", 3 | :locals => { :account => account } 4 | page[dom_id(account, :buckets)].replace_html("Buckets in #{h(account.name)}") 5 | else 6 | errors = account.errors.full_messages.join("\n") 7 | page.alert("The account could not be updated:\n\n" + errors) 8 | end 9 | -------------------------------------------------------------------------------- /app/views/events/_line_item.html.haml: -------------------------------------------------------------------------------- 1 | == $#{text_field_tag "event[#{section}][amount]", line_item_amount_value(line_item), :size => 8, :class => "number", :onchange => "Events.updateUnassignedFor('#{section}')"} 2 | = bucket_action_phrase_for(section) 3 | = select_bucket(section, :splittable => false, :line_item => line_item) 4 | = link_to_function "[x]", "Events.removeLineItem(this.up('li'))" 5 | -------------------------------------------------------------------------------- /config/initializers/redirect_log.rb: -------------------------------------------------------------------------------- 1 | # For use from the console, so you can view the log output without 2 | # having to tail a log in a separate terminal window. 3 | def redirect_log(options={}) 4 | ActiveRecord::Base.logger = Logger.new(options.fetch(:to, STDERR)) 5 | ActiveRecord::Base.clear_active_connections! 6 | ActiveRecord::Base.colorize_logging = options.fetch(:colorize, true) 7 | end 8 | -------------------------------------------------------------------------------- /app/views/tags/show.html.haml: -------------------------------------------------------------------------------- 1 | #delete_form.form{:style => "display: none;"}= render :partial => "tags/delete_form" 2 | 3 | #data.content 4 | .navigation 5 | = link_to "Dashboard", subscription_path(subscription) 6 | 7 | %h2#name= render :partial => "tags/name" 8 | 9 | %table.entries 10 | = render :partial => "events/balance", :locals => { :container => tag_ref } 11 | = render(@items) 12 | -------------------------------------------------------------------------------- /app/concerns/categorized_items.rb: -------------------------------------------------------------------------------- 1 | module CategorizedItems 2 | def deposits 3 | @deposits ||= to_a.select { |item| item.amount > 0 } 4 | end 5 | 6 | def checks 7 | @checks ||= to_a.select { |item| item.amount < 0 && item.event.check_number.present? } 8 | end 9 | 10 | def expenses 11 | @expenses ||= to_a.select { |item| item.amount < 0 && item.event.check_number.blank? } 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/accounts/_account.html.haml: -------------------------------------------------------------------------------- 1 | %tr.account{:id => dom_id(account)} 2 | %th= link_to(account.name, account) 3 | = balance_cell(account, :classes => "total") 4 | - reset_cycle 5 | = render(account.buckets.recent) 6 | - if account.buckets.length > 5 7 | %tr.bucket 8 | %td.more{:colspan => 2} 9 | → 10 | = link_to "See all #{account.buckets.length} buckets", account_buckets_path(account) 11 | -------------------------------------------------------------------------------- /app/views/events/_balance.html.haml: -------------------------------------------------------------------------------- 1 | %tr.current_balance 2 | - if container.is_a?(Statement) 3 | %th.date= statement.occurred_on.strftime("%Y-%m-%d") 4 | %th Ending balance 5 | - else 6 | %th.date= Date.today.strftime("%Y-%m-%d") 7 | %th Current balance 8 | %th.number.total.balance{:colspan => 2}= balance_cell(container, :tag => "span", :id => "balance") 9 | %tr.spacer 10 | %td{:colspan => 4} 11 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/tasks/git.rake: -------------------------------------------------------------------------------- 1 | namespace :git do 2 | namespace :hooks do 3 | desc "Install some git hooks for updating cached externals" 4 | task :install do 5 | Dir["#{RAILS_ROOT}/vendor/plugins/cached_externals/script/git-hooks/*"].each do |hook| 6 | cp hook, ".git/hooks" 7 | chmod 0755, ".git/hooks/#{File.basename(hook)}" 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/helpers/accounts_helper.rb: -------------------------------------------------------------------------------- 1 | module AccountsHelper 2 | def account_starting_balance_amount 3 | if @account && @account.starting_balance 4 | amount = @account.starting_balance[:amount].to_i 5 | "%.2f" % (amount / 100.0) unless amount.zero? 6 | end 7 | end 8 | 9 | def account_starting_balance_occurred_on 10 | @account.starting_balance[:occurred_on].to_date rescue Date.today 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/views/accounts/show.html.haml: -------------------------------------------------------------------------------- 1 | #data.content 2 | .navigation 3 | = link_to "Dashboard", subscription_path(subscription) 4 | = link_to "Buckets in #{h(account.name)}", account_buckets_path(account), :id => dom_id(account, :buckets) 5 | 6 | %h2{:id => dom_id(account, :name)}= render :partial => "accounts/name" 7 | 8 | %table.entries 9 | = render :partial => "events/balance", :locals => { :container => account } 10 | = render(@items) 11 | -------------------------------------------------------------------------------- /test/fixtures/tagged_items.yml: -------------------------------------------------------------------------------- 1 | john_lunch_tip: 2 | event: john_lunch 3 | tag: john_tip 4 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %> 5 | amount: 100 6 | 7 | john_lunch_lunch: 8 | event: john_lunch 9 | tag: john_lunch 10 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %> 11 | amount: 775 12 | 13 | john_bare_mastercard: 14 | event: john_bare_mastercard 15 | tag: john_fuel 16 | occurred_on: <%= 55.days.ago.to_date.to_s(:db) %> 17 | amount: 1500 18 | -------------------------------------------------------------------------------- /app/views/events/_tagged_item.html.haml: -------------------------------------------------------------------------------- 1 | == $#{text_field_tag "event[tags][amount]", tagged_item_amount_value(tagged_item), :size => 8, :class => "number"} 2 | is tagged as 3 | = tag_entry_field "event[tags][name]", tagged_item_name_value(tagged_item), :size => 12, :class => "tag", :id => tagged_item ? dom_id(tagged_item, :tag_name) : "{ID}" 4 | - if tagged_item 5 | :javascript 6 | Events.autocomplete_tag_field('#{dom_id(tagged_item, :tag_name)}'); 7 | = link_to_function "[x]", "Events.removeTaggedItem(this.up('li'))" 8 | -------------------------------------------------------------------------------- /app/views/buckets/index.html.haml: -------------------------------------------------------------------------------- 1 | :javascript 2 | Buckets.newEventUrl = #{new_subscription_event_path(subscription).to_json} 3 | 4 | #data.content 5 | .navigation 6 | = link_to "Dashboard", subscription_path(subscription) 7 | = link_to "Transactions in #{h(account.name)}", account_path(account) 8 | 9 | %h2 10 | = balance_cell(account, :classes => %w(total balance), :tag => "span") 11 | == Buckets in #{h(account.name)} 12 | 13 | %table.account_summary 14 | = render(account.buckets.sort_by(&:name)) 15 | -------------------------------------------------------------------------------- /app/views/buckets/_bucket_name_for_index.html.haml: -------------------------------------------------------------------------------- 1 | .nubbin{:id => dom_id(bucket, :nubbin), :style => "display: none"} 2 | = link_to_function("+", "Buckets.transferTo(#{bucket.account_id}, #{bucket.id})", :class => "button") 3 | = link_to_function("-", "Buckets.transferFrom(#{bucket.account_id}, #{bucket.id})", :class => "button") 4 | = link_to_function("✎", "Buckets.rename(#{bucket_path(bucket).to_json}, #{bucket.name.to_json}, #{form_authenticity_token.to_json})", :class => "button") 5 | = link_to(h(bucket.name), bucket, :class => "bucket") 6 | -------------------------------------------------------------------------------- /app/concerns/pageable.rb: -------------------------------------------------------------------------------- 1 | module Pageable 2 | DEFAULT_PAGE_SIZE = 100 3 | 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | end 7 | 8 | module ClassMethods 9 | def page(n, options={}) 10 | n = n.to_i 11 | size = (options[:size] || DEFAULT_PAGE_SIZE).to_i 12 | 13 | records = find(:all, :include => { :event => :line_items }, 14 | :order => "occurred_on DESC", 15 | :limit => size + 1, 16 | :offset => n * size) 17 | 18 | [records.length > size, records[0,size]] 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | ActionController::Routing::Routes.draw do |map| 2 | map.resource :session 3 | 4 | map.resources :subscriptions, :has_many => [:accounts, :events, :tags] 5 | map.resources :events, :has_many => :tagged_items, :member => { :update => :post } 6 | map.resources :buckets, :has_many => :events 7 | map.resources :accounts, :has_many => [:buckets, :events, :statements] 8 | map.resources :tags, :has_many => :events 9 | map.resources :tagged_items, :statements 10 | 11 | map.connect "", :controller => "subscriptions", :action => "index" 12 | end 13 | -------------------------------------------------------------------------------- /app/views/subscriptions/_blank_slate.html.haml: -------------------------------------------------------------------------------- 1 | %h2 Welcome to Buckets! 2 | 3 | %p To get started, you'll need to create at least one account | 4 | (a bank account, credit card account, etc). "Accounts" are where your money is | 5 | actually stored. | 6 | 7 | %p.create== [ #{link_to_function("Click here to create your first account", "Accounts.revealForm()")} ] 8 | 9 | %p Once you've created your first account, you can create more accounts, | 10 | record transactions, and basically get on with keeping your finances | 11 | under control! | 12 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3-ruby (not necessary on OS X Leopard) 3 | development: 4 | adapter: sqlite3 5 | database: db/development.sqlite3 6 | timeout: 5000 7 | 8 | # Warning: The database defined as 'test' will be erased and 9 | # re-generated from your development database when you run 'rake'. 10 | # Do not set this db to the same as development or production. 11 | test: 12 | adapter: sqlite3 13 | database: db/test.sqlite3 14 | timeout: 5000 15 | 16 | production: 17 | adapter: sqlite3 18 | database: db/production.sqlite3 19 | timeout: 5000 20 | -------------------------------------------------------------------------------- /app/views/buckets/_delete_form.html.haml: -------------------------------------------------------------------------------- 1 | - form_tag(bucket_path(bucket), :method => 'delete', :onsubmit => "return Buckets.confirmDelete()") do 2 | %fieldset 3 | %legend Delete/Merge 4 | %p== To delete this bucket, you'll need to move the transactions from "#{h(bucket.name)}" into another bucket. 5 | %p 6 | Please select that other bucket: 7 | = select_tag(:receiver_id, options_from_collection_for_select(possible_receiver_buckets, :id, :name, account.buckets.default.id)) 8 | %p 9 | = submit_tag "Merge and delete this bucket" 10 | == or #{link_to_function("Cancel", "Buckets.cancelDelete()")} 11 | -------------------------------------------------------------------------------- /test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | john: 2 | name: John Johnson 3 | email: jjohnson@domain.test 4 | user_name: jjohnson 5 | password_hash: "d8fa32a5d48cf8104af1cd40f5132f894d6eb765" # hash of 'testing' 6 | salt: abcd1234 7 | created_at: <%= 60.days.ago.utc.to_s(:db) %> 8 | updated_at: <%= 60.days.ago.utc.to_s(:db) %> 9 | 10 | tim: 11 | name: Tim Taylor 12 | email: ttaylor@domain.test 13 | user_name: ttaylor 14 | password_hash: "d8fa32a5d48cf8104af1cd40f5132f894d6eb765" # hash of 'testing' 15 | salt: abcd1234 16 | created_at: <%= 59.days.ago.utc.to_s(:db) %> 17 | updated_at: <%= 59.days.ago.utc.to_s(:db) %> 18 | -------------------------------------------------------------------------------- /app/views/buckets/show.html.haml: -------------------------------------------------------------------------------- 1 | #delete_form.form{:style => "display: none;"}= render :partial => "buckets/delete_form" 2 | 3 | #data.content 4 | .navigation 5 | = link_to "Dashboard", subscription_path(subscription) 6 | = link_to "Transactions in #{h(account.name)}", account_path(account) 7 | = link_to "Buckets in #{h(account.name)}", account_buckets_path(account) 8 | 9 | %h2{:id => dom_id(bucket, :name)} 10 | = render :partial => "buckets/bucket_name_for_perma", :locals => { :bucket => bucket } 11 | 12 | %table.entries 13 | = render :partial => "events/balance", :locals => { :container => bucket } 14 | = render(@items) 15 | -------------------------------------------------------------------------------- /app/views/statements/show.html.haml: -------------------------------------------------------------------------------- 1 | #data.content 2 | .navigation 3 | = link_to "Dashboard", subscription_path(subscription) 4 | = link_to h(account.name), account_path(account) 5 | = link_to "Prior statements", account_statements_path(account) 6 | 7 | %h2 8 | %span.actions 9 | = link_to("Delete", statement_path(statement), :confirm => "Are you sure you want to delete this statement?", :method => :delete) 10 | Statement for period ending 11 | = statement.occurred_on.strftime("%Y-%m-%d") 12 | 13 | %table.entries 14 | = render :partial => "events/balance", :locals => { :container => statement } 15 | = render(statement.account_items) 16 | -------------------------------------------------------------------------------- /test/unit/tagged_item_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TaggedItemTest < ActiveSupport::TestCase 4 | test "create should update balance on tag record" do 5 | initial_amount = tags(:john_lunch).balance 6 | events(:john_bill_pay).tagged_items.create({:tag => tags(:john_lunch), :amount => 1500}, :occurred_on => Date.today) 7 | assert_equal initial_amount + 1500, tags(:john_lunch, :reload).balance 8 | end 9 | 10 | test "destroy should update balance on tag record" do 11 | assert_not_equal 0, tags(:john_lunch).balance 12 | tagged_items(:john_lunch_lunch).destroy 13 | assert_equal 0, tags(:john_lunch, :reload).balance 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/controllers/subscriptions_controller.rb: -------------------------------------------------------------------------------- 1 | class SubscriptionsController < ApplicationController 2 | before_filter :find_subscription, :except => :index 3 | 4 | def index 5 | respond_to do |format| 6 | format.html do 7 | if user.subscriptions.length == 1 8 | redirect_to(subscription_url(user.subscriptions.first)) 9 | return 10 | end 11 | end 12 | 13 | format.xml { render :xml => user.subscriptions.to_xml(:root => "subscriptions") } 14 | end 15 | end 16 | 17 | def show 18 | respond_to do |format| 19 | format.html 20 | format.xml { render :xml => subscription } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/accounts/_name.html.haml: -------------------------------------------------------------------------------- 1 | %span.actions 2 | - if account.statements.pending.any? 3 | = link_to("Resume reconciling", edit_statement_path(account.statements.pending.first)) 4 | - else 5 | = link_to("Reconcile", new_account_statement_path(account)) 6 | | 7 | - if account.statements.balanced.any? 8 | = link_to("Prior statements", account_statements_path(account)) 9 | | 10 | = link_to_function("Rename", "Accounts.rename(#{account_path(account).to_json}, #{account.name.to_json}, #{form_authenticity_token.to_json})") 11 | | 12 | = link_to("Delete", account_path(account), :method => :delete, :confirm => "Are you sure you want to delete this account?") 13 | &= account.name 14 | -------------------------------------------------------------------------------- /app/views/statements/index.html.haml: -------------------------------------------------------------------------------- 1 | #data.content 2 | .navigation 3 | = link_to "Dashboard", subscription_path(subscription) 4 | = link_to h(account.name), account_path(account) 5 | 6 | %h2 Previous Statements 7 | 8 | - if statements.empty? 9 | %p== You have not yet balanced this account against any statements from your financial institution. You may start by clicking #{link_to("here", new_account_statement_path(account))}, to begin balancing this account. 10 | 11 | - else 12 | %ul 13 | - statements.each do |statement| 14 | %li 15 | = link_to(statement.occurred_on.strftime("%Y-%m-%d"), statement_path(statement)) 16 | = ":" 17 | = format_cents(statement.ending_balance) 18 | -------------------------------------------------------------------------------- /app/views/events/_expand.html.haml: -------------------------------------------------------------------------------- 1 | %tr.zoom{:id => dom_id(event, :zoomed)} 2 | %td.zoom{:colspan => 4} 3 | .detail 4 | %table 5 | - reset_cycle('expanded'); event.line_items.each do |item| 6 | %tr{:class => cycle('odd', 'even', :name => 'expanded')} 7 | %td= link_to(h(item.account.name), item.account) 8 | %td= link_to(h(item.bucket.name), item.bucket) 9 | %td.number= item.amount > 0 ? format_amount(item.amount) : "" 10 | %td.negative.number= item.amount < 0 ? format_amount(item.amount) : "" 11 | 12 | - if event.tags.any? 13 | %p.tags= "✓ " + tag_links_for(event) 14 | 15 | - if event.memo.present? 16 | %p.memo&= event.memo 17 | 18 | -------------------------------------------------------------------------------- /config/initializers/new_rails_defaults.rb: -------------------------------------------------------------------------------- 1 | # These settins change the behavior of Rails 2 apps and will be defaults 2 | # for Rails 3. You can remove this initializer when Rails 3 is released. 3 | 4 | # Only save the attributes that have changed since the record was loaded. 5 | ActiveRecord::Base.partial_updates = true 6 | 7 | # Include ActiveRecord class name as root for JSON serialized output. 8 | ActiveRecord::Base.include_root_in_json = true 9 | 10 | # Use ISO 8601 format for JSON serialized times and dates 11 | ActiveSupport.use_standard_json_time_format = true 12 | 13 | # Don't escape HTML entities in JSON, leave that for the #json_escape helper 14 | # if you're including raw json in an HTML page. 15 | ActiveSupport.escape_html_entities_in_json = false -------------------------------------------------------------------------------- /app/views/events/create.js.rjs: -------------------------------------------------------------------------------- 1 | if !event.valid? 2 | errors = event.errors.full_messages.join("\n") 3 | page.alert("This transaction could not be saved:\n\n" + errors) 4 | 5 | elsif params[:source] == "new" 6 | page.redirect_to(subscription_url(subscription)) 7 | 8 | else 9 | page['recent_entries'].replace_html :partial => "events/event", 10 | :collection => subscription.events.recent.last 11 | page['accounts_summary'].replace_html :partial => "accounts/account", 12 | :collection => subscription.accounts 13 | page.events.reset 14 | page[:success_notice].show 15 | page << "Events.accounts = #{accounts_and_buckets_as_javascript}" 16 | page << "Events.tags = #{tags_as_javascript}" 17 | page.visual_effect :highlight, dom_id(event) 18 | end 19 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file 2 | 3 | # Uncomment below to force Rails into production mode when 4 | # you don't control web/app server and can't set it the proper way 5 | # ENV['RAILS_ENV'] ||= 'production' 6 | 7 | # Specifies gem version of Rails to use when vendor/rails is not present 8 | RAILS_GEM_VERSION = '2.3.2' unless defined? RAILS_GEM_VERSION 9 | 10 | # Bootstrap the Rails environment, frameworks, and default configuration 11 | require File.join(File.dirname(__FILE__), 'boot') 12 | 13 | Rails::Initializer.run do |config| 14 | config.load_paths += %W( #{RAILS_ROOT}/app/concerns ) 15 | config.time_zone = 'UTC' 16 | config.action_controller.session = YAML.load_file("#{RAILS_ROOT}/config/session.yml") 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20090404154634_cache_balances_on_buckets_and_accounts.rb: -------------------------------------------------------------------------------- 1 | class CacheBalancesOnBucketsAndAccounts < ActiveRecord::Migration 2 | def self.up 3 | add_column :accounts, :balance, :integer, :null => false, :default => 0 4 | add_column :buckets, :balance, :integer, :null => false, :default => 0 5 | 6 | Account.find_each do |account| 7 | balance = account.account_items.sum(:amount) 8 | account.update_attribute :balance, balance 9 | end 10 | 11 | Bucket.find_each do |bucket| 12 | balance = bucket.line_items.sum(:amount) 13 | bucket.update_attribute :balance, balance 14 | end 15 | end 16 | 17 | def self.down 18 | remove_column :accounts, :balance, :integer 19 | remove_column :buckets, :balance, :integer 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | skip_before_filter :authenticate 3 | 4 | layout nil 5 | 6 | def new 7 | end 8 | 9 | def create 10 | @user = User.authenticate(params[:user_name], params[:password]) 11 | 12 | if @user.nil? 13 | flash[:failed] = true 14 | redirect_to(new_session_url) 15 | else 16 | session[:user_id] = @user.id 17 | 18 | if @user.subscriptions.length > 1 19 | redirect_to(subscriptions_url) 20 | else 21 | redirect_to(subscription_url(@user.subscriptions.first)) 22 | end 23 | end 24 | end 25 | 26 | def destroy 27 | flash[:logged_out] = true 28 | session[:user_id] = nil 29 | redirect_to(new_session_url) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /config/externals.yml: -------------------------------------------------------------------------------- 1 | vendor/rails: 2 | :type: git 3 | :repository: git://github.com/rails/rails.git 4 | :revision: v2.3.2.1 5 | vendor/plugins/calendar_date_select: 6 | :type: git 7 | :repository: git://github.com/timcharper/calendar_date_select.git 8 | :revision: 88b7caf7acecf31186661c0efd6bc606cdcc666d 9 | vendor/plugins/haml: 10 | :type: git 11 | :repository: git://github.com/nex3/haml.git 12 | :revision: 2.0.9 13 | vendor/plugins/project_search: 14 | :type: git 15 | :repository: git://github.com/37signals/project_search.git 16 | :revision: 5d243711fbbd69ac08ba86418316bd15cffa0642 17 | vendor/plugins/safe_mass_assignment: 18 | :type: git 19 | :repository: git://github.com/jamis/safe_mass_assignment.git 20 | :revision: 7d7e24e551254e0651f5cfb93d876beaed9ed7f7 21 | -------------------------------------------------------------------------------- /db/migrate/20090421221109_add_statements.rb: -------------------------------------------------------------------------------- 1 | class AddStatements < ActiveRecord::Migration 2 | def self.up 3 | create_table :statements do |t| 4 | t.integer :account_id, :null => false 5 | t.date :occurred_on, :null => false 6 | t.integer :starting_balance 7 | t.integer :ending_balance 8 | t.datetime :balanced_at 9 | t.timestamps 10 | end 11 | 12 | add_index :statements, %w(account_id occurred_on) 13 | 14 | add_column :account_items, :statement_id, :integer 15 | add_index :account_items, %w(statement_id occurred_on) 16 | end 17 | 18 | def self.down 19 | drop_table :statements 20 | 21 | remove_index :account_items, %w(statement_id occurred_on) 22 | remove_column :account_items, :statement_id 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | require File.expand_path(File.dirname(__FILE__) + "/../config/environment") 3 | require 'test_help' 4 | 5 | class ActiveSupport::TestCase 6 | fixtures :all 7 | 8 | protected 9 | 10 | def login_default_user 11 | login! :john 12 | end 13 | 14 | private 15 | 16 | def login!(who) 17 | @user = Symbol === who ? users(who) : who 18 | @request.session[:user_id] = @user.id 19 | end 20 | 21 | def logout! 22 | @request.session[:user_id] = nil 23 | end 24 | 25 | def api_login!(who, password) 26 | logout! 27 | @user = Symbol === who ? users(who) : who 28 | token = Base64.encode64("#{@user.user_name}:#{password}") 29 | @request.env['HTTP_AUTHORIZATION'] = "Basic: #{token}" 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/views/events/_form_reallocate.html.haml: -------------------------------------------------------------------------------- 1 | %fieldset{:id => section, :style => section_visible_for_event?(section) ? nil : "display: none"} 2 | %legend Reallocate funds 3 | 4 | %p.primary 5 | = hidden_field_tag "account_for_#{section}", account_id_for_section(section) 6 | 7 | == Which bucket are you reallocating funds #{reallocation_verbs_for(section).first}? 8 | = select_bucket(section, :splittable => false, :line_item => line_item_for_section('primary')) 9 | 10 | %ol{:id => "#{section}.line_items"} 11 | - for_each_line_item_in(section) do |item| 12 | %li= render :partial => "events/reallocation_item", :object => item, :locals => { :section => section } 13 | 14 | %p= link_to_function "More buckets, please!", "Events.addLineItemTo('#{section}', true)" 15 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # Settings specified here will take precedence over those in config/environment.rb 2 | 3 | # In the development environment your application's code is reloaded on 4 | # every request. This slows down response time but is perfect for development 5 | # since you don't have to restart the webserver when you make code changes. 6 | config.cache_classes = false 7 | 8 | # Log error messages when you accidentally call methods on nil. 9 | config.whiny_nils = true 10 | 11 | # Show full error reports and disable caching 12 | config.action_controller.consider_all_requests_local = true 13 | config.action_view.debug_rjs = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send 17 | config.action_mailer.raise_delivery_errors = false -------------------------------------------------------------------------------- /app/views/statements/_uncleared.html.haml: -------------------------------------------------------------------------------- 1 | %tr{:id => dom_id(uncleared), :class => uncleared_row_class(uncleared)} 2 | %td.checkbox= check_box_tag "statement[cleared][]", uncleared.id, uncleared.statement_id, :id => dom_id(uncleared, :check), :onclick => "Statements.toggleCleared(#{uncleared.id})" 3 | %td.date{:onclick => "Statements.clickItem(#{uncleared.id})"}= uncleared.occurred_on.strftime("%Y-%m-%d") 4 | - if uncleared.event.check_number 5 | %td.check{:onclick => "Statements.clickItem(#{uncleared.id})"}= "#" + uncleared.event.check_number.to_s 6 | %td.actor{:onclick => "Statements.clickItem(#{uncleared.id})"}&= uncleared.event.actor 7 | %td.number{:onclick => "Statements.clickItem(#{uncleared.id})"} 8 | %span{:style => "display: none;", :id => dom_id(uncleared, :amount)}= uncleared.amount 9 | = format_cents(uncleared.amount.abs) 10 | -------------------------------------------------------------------------------- /app/views/sessions/new.html.haml: -------------------------------------------------------------------------------- 1 | !!! 2 | %html 3 | %head 4 | %meta{"http-equiv" => "Content-Type", :content => "text/html; charset=utf-8"}/ 5 | %title Login to Buckets 6 | = stylesheet_link_tag "money" 7 | = javascript_include_tag "prototype" 8 | 9 | %body.login 10 | .login_form 11 | - if flash[:failed] 12 | .alert The user name or password you gave was incorrect. Please try again. 13 | - if flash[:logged_out] 14 | .notice You have been logged out. 15 | - form_tag(:session, :url => session_path) do 16 | %p 17 | User name: 18 | = text_field_tag :user_name 19 | %p 20 | Password: 21 | = password_field_tag :password 22 | %p= submit_tag "Log into Buckets" 23 | 24 | :javascript 25 | window.onload = function() { $('user_name').focus(); } 26 | -------------------------------------------------------------------------------- /app/views/tags/_delete_form.html.haml: -------------------------------------------------------------------------------- 1 | - form_tag(tag_path(tag_ref), :method => 'delete', :onsubmit => "return Tags.confirmDelete()") do 2 | - if possible_receiver_tags.any? 3 | %fieldset 4 | %legend Delete/Merge 5 | 6 | %label#deleteTagOption.option.selected 7 | = radio_button_tag(:merge, 'no', true, :onclick => "Tags.selectDeleteTag()") 8 | == Delete the "#{h tag_ref.name}" tag 9 | 10 | %label#mergeTagOption.option 11 | = radio_button_tag(:merge, 'yes', false, :onclick => "Tags.selectMergeTag()") 12 | == Merge the "#{h tag_ref.name}" tag into 13 | = select_tag(:receiver_id, "" + options_from_collection_for_select(possible_receiver_tags, :id, :name)) 14 | 15 | %p 16 | = submit_tag "Delete this tag" 17 | or 18 | = link_to_function("Cancel", "Tags.cancelDelete()") 19 | -------------------------------------------------------------------------------- /app/views/events/destroy.js.rjs: -------------------------------------------------------------------------------- 1 | page.events.destroy(event.id) 2 | 3 | case params[:from] 4 | when "subscriptions", "events" then 5 | page['accounts_summary'].replace_html(:partial => "accounts/account", 6 | :collection => subscription.accounts(:reload)) 7 | when /accounts\/(\d+)/ then 8 | account = subscription.accounts.find($1) 9 | page['balance'].replace(balance_cell(account, :classes => %w(total balance), :tag => "span", :id => "balance")) 10 | when /buckets\/(\d+)/ then 11 | bucket = Bucket.find($1) 12 | subscription.accounts.find(bucket.account_id) 13 | page['balance'].replace(balance_cell(bucket, :classes => %w(total balance), :tag => "span", :id => "balance")) 14 | when /tags\/(\d+)/ then 15 | tag = subscription.tags.find($1) 16 | page['balance'].replace(balance_cell(tag, :classes => %w(total balance), :tag => "span", :id => "balance")) 17 | end 18 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # Methods added to this helper will be available to all templates in the application. 2 | module ApplicationHelper 3 | def visible?(flag) 4 | if flag 5 | nil 6 | else 7 | "display: none" 8 | end 9 | end 10 | 11 | def application_revision 12 | @application_revision ||= if File.exists?("#{RAILS_ROOT}/REVISION") 13 | File.read("#{RAILS_ROOT}/REVISION").strip 14 | else 15 | "HEAD" 16 | end 17 | end 18 | 19 | def application_last_deployed 20 | if File.exists?("#{RAILS_ROOT}/REVISION") 21 | @deployed_at ||= File.stat("#{RAILS_ROOT}/REVISION").ctime 22 | time_ago_in_words(@deployed_at) + " ago" 23 | else 24 | "(not deployed)" 25 | end 26 | end 27 | 28 | def format_cents(amount, options={}) 29 | number_to_currency(amount/100.0, options) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/tasks/subscription.rake: -------------------------------------------------------------------------------- 1 | namespace :subscription do 2 | desc "List all users for a particular subscription (SUBSCRIPTION_ID env var)" 3 | task :users => :environment do 4 | subscription = Subscription.find(ENV['SUBSCRIPTION_ID']) 5 | 6 | if subscription.users.empty? 7 | puts "No users have access to subscription ##{subscription.id}" 8 | else 9 | puts "Users with access to ##{subscription.id}" 10 | subscription.users.each do |user| 11 | puts "##{user.id}: \"#{user.name}\" <#{user.email}>" 12 | end 13 | end 14 | end 15 | 16 | desc "Create a new subscription (USER_ID env var for owner)" 17 | task :create => :environment do 18 | owner = User.find(ENV['USER_ID']) 19 | subscription = Subscription.create(:owner => owner) 20 | owner.subscriptions << subscription 21 | puts "subscription ##{subscription.id} created for #{owner.user_name}" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.haml: -------------------------------------------------------------------------------- 1 | !!! 2 | %html{:lang => "en"} 3 | %head 4 | %meta{"http-equiv" => "Content-Type", :content => "text/html; charset=utf-8"}/ 5 | %title Summary 6 | = stylesheet_link_tag "money" 7 | = javascript_include_tag :all 8 | = calendar_date_select_includes "default" 9 | 10 | %body 11 | :javascript 12 | Events.source = #{current_location.to_json}; 13 | 14 | #header 15 | %h1 16 | %span.actions 17 | = link_to "logout", session_path, :method => :delete 18 | BucketWise 19 | 20 | #container 21 | = yield 22 | #version 23 | == BucketWise version: v#{BucketWise::Version::STRING} (rev #{application_revision}) 24 | %br 25 | == Last deployed: #{application_last_deployed} 26 | -------------------------------------------------------------------------------- /app/models/tag.rb: -------------------------------------------------------------------------------- 1 | class Tag < ActiveRecord::Base 2 | belongs_to :subscription 3 | 4 | has_many :tagged_items, :dependent => :delete_all 5 | 6 | attr_accessible :name 7 | 8 | validates_presence_of :name 9 | validates_uniqueness_of :name, :scope => :subscription_id, :case_sensitive => false 10 | 11 | def self.template 12 | new :name => "name of tag" 13 | end 14 | 15 | def assimilate(tag) 16 | raise ActiveRecord::RecordNotSaved, "cannot assimilate self" if tag == self 17 | 18 | transaction do 19 | connection.update <<-SQL.squish 20 | UPDATE tagged_items 21 | SET tag_id = #{id} 22 | WHERE tag_id = #{tag.id} 23 | SQL 24 | tag.tagged_items.reset 25 | 26 | update_attribute :balance, balance + tag.balance 27 | tag.destroy 28 | end 29 | end 30 | 31 | def to_xml(options={}) 32 | options[:only] = Array(options[:only]) + [:name] if new_record? 33 | super(options) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/views/statements/new.html.haml: -------------------------------------------------------------------------------- 1 | #data.content 2 | %h2== Let's reconcile your #{h(account.name)} account 3 | 4 | .form 5 | 6 | - form_for([account, statement]) do |form| 7 | 8 | %fieldset 9 | 10 | %p First, take a look at the account statement from your bank or other financial institution. 11 | 12 | %p If you haven't reconciled in a while, make sure you start with the oldest statement and work forward. 13 | 14 | %p 15 | When was the statement printed? 16 | = form.calendar_date_select :occurred_on, :size => 10 17 | 18 | %p 19 | What is the ending balance? 20 | = "$" + form.text_field(:ending_balance, :size => 8, :class => "number", :value => format_cents(statement.ending_balance, :unit => ""), :onchange => "this.value = Money.format(this)") 21 | 22 | %p 23 | = form.submit "Go to step #2" 24 | or 25 | = link_to("cancel", account_path(account)) 26 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | # Settings specified here will take precedence over those in config/environment.rb 2 | 3 | # The production environment is meant for finished, "live" apps. 4 | # Code is not reloaded between requests 5 | config.cache_classes = true 6 | 7 | # Use a different logger for distributed setups 8 | # config.logger = SyslogLogger.new 9 | 10 | # Full error reports are disabled and caching is turned on 11 | config.action_controller.consider_all_requests_local = false 12 | config.action_controller.perform_caching = true 13 | config.action_view.cache_template_loading = true 14 | 15 | # Use a different cache store in production 16 | # config.cache_store = :mem_cache_store 17 | 18 | # Enable serving of images, stylesheets, and javascripts from an asset server 19 | # config.action_controller.asset_host = "http://assets.example.com" 20 | 21 | # Disable delivery errors, bad email addresses will be ignored 22 | # config.action_mailer.raise_delivery_errors = false 23 | -------------------------------------------------------------------------------- /app/helpers/subscriptions_helper.rb: -------------------------------------------------------------------------------- 1 | module SubscriptionsHelper 2 | def blank_slate? 3 | subscription.accounts.empty? 4 | end 5 | 6 | def balance_cell(container, options={}) 7 | balance = real_balance = container.balance 8 | if container.respond_to?(:available_balance) 9 | balance = container.available_balance 10 | end 11 | 12 | classes = %w(number) 13 | classes += Array(options[:classes]) if options[:classes] 14 | classes << "negative" if balance < 0 15 | 16 | content = format_amount(balance) 17 | if real_balance != balance 18 | content = "(" << format_amount(real_balance) << ") #{content}" 19 | end 20 | 21 | content_tag(options.fetch(:tag, "td"), content, :class => classes.join(" "), :id => options[:id]) 22 | end 23 | 24 | def format_amount(amount) 25 | amount = amount.abs 26 | 27 | dollars = amount / 100 28 | cents = amount % 100 29 | 30 | "$%s.%02d" % [number_with_delimiter(dollars), cents] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | The change you wanted was rejected (422) 9 | 21 | 22 | 23 | 24 | 25 |
26 |

The change you wanted was rejected.

27 |

Maybe you tried to change something you didn't have access to.

28 |
29 | 30 | -------------------------------------------------------------------------------- /test/fixtures/tags.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------- 2 | # john 3 | # -------------------------------------------------------------- 4 | 5 | john_tip: 6 | subscription: john 7 | name: tip 8 | balance: 100 9 | created_at: <%= 59.days.ago.utc.to_s(:db) %> 10 | updated_at: <%= 59.days.ago.utc.to_s(:db) %> 11 | 12 | john_lunch: 13 | subscription: john 14 | name: lunch 15 | balance: 775 16 | created_at: <%= 59.days.ago.utc.to_s(:db) %> 17 | updated_at: <%= 59.days.ago.utc.to_s(:db) %> 18 | 19 | john_fuel: 20 | subscription: john 21 | name: fuel 22 | balance: 1500 23 | created_at: <%= 55.days.ago.utc.to_s(:db) %> 24 | updated_at: <%= 55.days.ago.utc.to_s(:db) %> 25 | 26 | # -------------------------------------------------------------- 27 | # tim 28 | # -------------------------------------------------------------- 29 | 30 | tim_milk: 31 | subscription: tim 32 | name: milk 33 | balance: 0 34 | created_at: <%= 58.days.ago.utc.to_s(:db) %> 35 | updated_at: <%= 58.days.ago.utc.to_s(:db) %> 36 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | The page you were looking for doesn't exist (404) 9 | 21 | 22 | 23 | 24 | 25 |
26 |

The page you were looking for doesn't exist.

27 |

You may have mistyped the address or the page may have moved.

28 |
29 | 30 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | We're sorry, but something went wrong (500) 9 | 21 | 22 | 23 | 24 | 25 |
26 |

We're sorry, but something went wrong.

27 |

We've been notified about this issue and we'll take a look at it shortly.

28 |
29 | 30 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # Settings specified here will take precedence over those in config/environment.rb 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | config.cache_classes = true 8 | 9 | # Log error messages when you accidentally call methods on nil. 10 | config.whiny_nils = true 11 | 12 | # Show full error reports and disable caching 13 | config.action_controller.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Disable request forgery protection in test environment 17 | config.action_controller.allow_forgery_protection = false 18 | 19 | # Tell ActionMailer not to deliver emails to the real world. 20 | # The :test delivery method accumulates sent emails in the 21 | # ActionMailer::Base.deliveries array. 22 | config.action_mailer.delivery_method = :test 23 | -------------------------------------------------------------------------------- /test/unit/account_item_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class AccountItemTest < ActiveSupport::TestCase 4 | test "create should update balance on account record" do 5 | initial_amount = accounts(:john_checking).balance 6 | 7 | subscriptions(:john).events.create({:occurred_on => 3.days.ago.to_date, 8 | :actor => "Something", 9 | :line_items => [ 10 | { :account_id => accounts(:john_checking).id, 11 | :bucket_id => buckets(:john_checking_groceries).id, 12 | :amount => -25_75, 13 | :role => 'payment_source' }, 14 | ] 15 | }, :user => users(:john)) 16 | 17 | assert_equal initial_amount - 25_75, accounts(:john_checking, :reload).balance 18 | end 19 | 20 | test "destroy should update balance on account record" do 21 | amount = account_items(:john_lunch_checking).amount 22 | current = accounts(:john_checking).balance 23 | account_items(:john_lunch_checking).destroy 24 | assert_equal current - amount, accounts(:john_checking, :reload).balance 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/concerns/option_handler.rb: -------------------------------------------------------------------------------- 1 | module OptionHandler 2 | private 3 | 4 | # For appending info to an XML serialization options hash, where the attributes 5 | # may be arrays, hashes, or singleton values. 6 | def append_to_options(options, attribute, extras) 7 | case options[attribute] 8 | when Array then 9 | case extras 10 | when Array then 11 | options[attribute].concat(extras) 12 | when Hash then 13 | old, options[attribute] = options[attribute], extras 14 | old.each { |key| options[attribute][key] ||= {} } 15 | else 16 | options[attribute] << extras 17 | end 18 | 19 | when Hash then 20 | case extras 21 | when Array then 22 | extras.each { |key| options[attribute][key] ||= {} } 23 | when Hash then 24 | extras.each { |key, value| options[attribute][key] ||= value } 25 | else 26 | options[attribute][extras] ||= {} 27 | end 28 | 29 | else 30 | options[attribute] = extras 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/unit/line_item_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class LineItemTest < ActiveSupport::TestCase 4 | test "create should update balance on bucket record" do 5 | initial_amount = buckets(:john_checking_groceries).balance 6 | 7 | subscriptions(:john).events.create({:occurred_on => 3.days.ago.to_date, 8 | :actor => "Something", 9 | :line_items => [ 10 | { :account_id => accounts(:john_checking).id, 11 | :bucket_id => buckets(:john_checking_groceries).id, 12 | :amount => -25_75, 13 | :role => 'payment_source' }, 14 | ]}, 15 | :user => users(:john)) 16 | 17 | assert_equal initial_amount - 25_75, buckets(:john_checking_groceries, :reload).balance 18 | end 19 | 20 | test "destroy should update balance on bucket record" do 21 | amount = line_items(:john_lunch_checking_dining).amount 22 | current = buckets(:john_checking_dining).balance 23 | line_items(:john_lunch_checking_dining).destroy 24 | assert_equal current - amount, buckets(:john_checking_dining, :reload).balance 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/fixtures/statements.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------- 2 | # john 3 | # -------------------------------------------------------------- 4 | 5 | john: 6 | account: john_checking 7 | occurred_on: <%= 2.weeks.ago.to_date.to_s(:db) %> 8 | starting_balance: 0 9 | ending_balance: 99225 10 | balanced_at: <%= 1.week.ago.to_s(:db) %> 11 | created_at: <%= 1.week.ago.to_s(:db) %> 12 | updated_at: <%= 1.week.ago.to_s(:db) %> 13 | 14 | john_pending: 15 | account: john_checking 16 | occurred_on: <%= Date.today %> 17 | starting_balance: 99225 18 | ending_balance: 125392 19 | balanced_at: ~ 20 | created_at: <%= Time.now.to_s(:db) %> 21 | updated_at: <%= Time.now.to_s(:db) %> 22 | 23 | # -------------------------------------------------------------- 24 | # tim 25 | # -------------------------------------------------------------- 26 | 27 | tim: 28 | account: tim_checking 29 | occurred_on: <%= 1.week.ago.to_date.to_s(:db) %> 30 | starting_balance: 0 31 | ending_balance: 123456 32 | balanced_at: ~ 33 | created_at: <%= 1.week.ago.to_s(:db) %> 34 | updated_at: <%= 1.week.ago.to_s(:db) %> 35 | -------------------------------------------------------------------------------- /app/views/subscriptions/show.html.haml: -------------------------------------------------------------------------------- 1 | #links.header{:style => visible?(!blank_slate?)} 2 | %h2== Record a new | 3 | #{link_to_function "expense", "Events.revealExpenseForm()", :id => "expense_link"}, | 4 | #{link_to_function "deposit", "Events.revealDepositForm()", :id => "deposit_link"}, or | 5 | #{link_to_function "transfer", "Events.revealTransferForm()", :id => "transfer_link"} | 6 | 7 | #new_event.transaction.form{:style => "display: none"}= render :partial => "events/form" 8 | 9 | - if blank_slate? 10 | #blankslate= render :partial => "blank_slate" 11 | 12 | #new_account.form{:style => "display: none"}= render :partial => "accounts/form" 13 | 14 | #data.content{:style => visible?(!blank_slate?)} 15 | %h2 Recent transactions 16 | 17 | %table#recent_entries.entries= render(subscription.events.recent.last) 18 | 19 | %h2#accounts_summary_header 20 | %span.actions= link_to_function "Add an account", "Accounts.revealForm()" 21 | Accounts summary 22 | 23 | %table#accounts_summary= render(subscription.accounts) 24 | 25 | - if subscription.tags.any? 26 | %h2 Tags 27 | = render :partial => "tags/cloud" 28 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 by 37signals, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | require 'digest/sha1' 2 | 3 | class User < ActiveRecord::Base 4 | include OptionHandler 5 | 6 | has_many :user_subscriptions 7 | has_many :subscriptions, :through => :user_subscriptions 8 | 9 | attr_accessible :name, :email, :user_name, :password 10 | 11 | attr_writer :password 12 | 13 | before_save :set_password_hash_and_salt 14 | 15 | validates_uniqueness_of :user_name 16 | 17 | def self.password_hash_for(password, salt) 18 | Digest::SHA1.hexdigest(salt + password) 19 | end 20 | 21 | def self.authenticate(user_name, password) 22 | user = find_by_user_name(user_name) or return nil 23 | hash = password_hash_for(password, user.salt) 24 | hash == user.password_hash ? user : nil 25 | end 26 | 27 | def to_xml(options={}) 28 | append_to_options(options, :except, [:password_hash, :salt]) 29 | super(options) 30 | end 31 | 32 | protected 33 | 34 | def set_password_hash_and_salt 35 | if @password 36 | self.salt = Array.new(32) { 32 + rand(95) }.pack("C*") 37 | self.password_hash = self.class.password_hash_for(@password, salt) 38 | @password = nil 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/views/events/_row.html.haml: -------------------------------------------------------------------------------- 1 | %tr{:id => dom_id(event), :class => cycle('odd', 'even'), :onmouseover => "Events.onMouseOver(#{event.id})", :onmouseout => "Events.onMouseOut(#{event.id})"} 2 | 3 | %td.date= event.occurred_on.strftime("%Y-%m-%d") 4 | 5 | %td 6 | .nubbin{:id => dom_id(event, :nubbin), :style => "display: none"} 7 | = link_to_function("✎", "Events.edit(#{edit_event_path(event).to_json})") 8 | = link_to_function("✖", "Events.deleteEvent(#{event_path(event).to_json}, #{form_authenticity_token.to_json})") 9 | = link_to_remote("ⓘ", :url => event_path(event), :method => :get, :before => "Events.expand(#{event.id})", :html => { :class => "zoom" }) 10 | %span.zooming ⌛ 11 | = link_to_function("✓", "Events.collapse(#{event.id})", :class => "zoomed") 12 | &= event.actor 13 | - if event.check_number.present? 14 | %span.check== ##{event.check_number} 15 | - if account_links 16 | %br/ 17 | %span.account_links= links_to_accounts_for_event(event) 18 | 19 | %td.number= amount > 0 ? format_amount(amount) : "" 20 | 21 | %td.negative.number= amount < 0 ? format_amount(amount) : "" 22 | -------------------------------------------------------------------------------- /app/controllers/tagged_items_controller.rb: -------------------------------------------------------------------------------- 1 | class TaggedItemsController < ApplicationController 2 | before_filter :find_event, :only => :create 3 | before_filter :find_tagged_item, :only => :destroy 4 | 5 | def create 6 | @tagged_item = event.tagged_items.create!(params[:tagged_item]) 7 | respond_to do |format| 8 | format.xml do 9 | render :status => :created, :location => tagged_item_url(@tagged_item), 10 | :xml => @tagged_item 11 | end 12 | end 13 | rescue ActiveRecord::RecordInvalid => error 14 | respond_to do |format| 15 | format.xml { render :status => :unprocessable_entity, :xml => error.record.errors } 16 | end 17 | end 18 | 19 | def destroy 20 | tagged_item.destroy 21 | respond_to { |format| format.xml { head :ok } } 22 | end 23 | 24 | protected 25 | 26 | attr_reader :event, :tagged_item 27 | helper_method :event, :tagged_item 28 | 29 | def find_event 30 | @event = Event.find(params[:event_id]) 31 | @subscription = user.subscriptions.find(@event.subscription_id) 32 | end 33 | 34 | def find_tagged_item 35 | @tagged_item = TaggedItem.find(params[:id]) 36 | @event = @tagged_item.event 37 | @subscription = user.subscriptions.find(@event.subscription_id) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/views/events/_form.html.haml: -------------------------------------------------------------------------------- 1 | :javascript 2 | Events.accounts = #{accounts_and_buckets_as_javascript}; 3 | Events.tags = #{tags_as_javascript}; 4 | 5 | #templates{:style => "display: none"} 6 | - form_sections.each do |section| 7 | %div{:id => "template.#{section}"} 8 | = render :partial => template_partial_for(section), :locals => { :section => section } 9 | 10 | - form_for(:event, event_for_form, :url => event_form_action, :html => { :id => "event_form", :onsubmit => "Events.submit(this); return false" }) do |form| 11 | #success_notice.notice{:style => "display: none"} 12 | %p== Your transaction has been recorded. You may enter another one, | 13 | or #{link_to_function "close this form", "Events.cancel()"}. | 14 | 15 | - if event_wants_section?('general_information') 16 | = render :partial => "events/form_general", :locals => { :form => form } 17 | - else 18 | :javascript 19 | Events.defaultDate = #{@event.occurred_on.strftime("%Y-%m-%d").to_json}; 20 | Events.defaultActor = #{@event.actor.to_json}; 21 | 22 | - form_sections.each do |section| 23 | = render_event_form_section(form, section) if event_wants_section?(section) 24 | 25 | %p 26 | %input{:type => "submit", :value => "Record this transaction"}/ 27 | or 28 | = link_to_function "cancel", "Events.cancel()" 29 | -------------------------------------------------------------------------------- /test/fixtures/accounts.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------- 2 | # john 3 | # -------------------------------------------------------------- 4 | 5 | john_checking: 6 | subscription: john 7 | author: john 8 | name: Checking 9 | role: checking 10 | balance: 99225 11 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 12 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 13 | 14 | john_mastercard: 15 | subscription: john 16 | author: john 17 | name: Mastercard 18 | role: credit-card 19 | balance: -2525 20 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 21 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 22 | 23 | john_savings: 24 | subscription: john 25 | author: john 26 | name: Savings 27 | role: ~ 28 | balance: 0 29 | created_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %> 30 | updated_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %> 31 | 32 | # -------------------------------------------------------------- 33 | # tim 34 | # -------------------------------------------------------------- 35 | 36 | tim_checking: 37 | subscription: tim 38 | author: tim 39 | name: Checking 40 | role: checking 41 | balance: 125000 42 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 43 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 44 | -------------------------------------------------------------------------------- /app/views/events/_form_tags.html.haml: -------------------------------------------------------------------------------- 1 | %p#tags_collapsed{:style => event_has_tags? ? "display: none" : nil} 2 | → 3 | = link_to_function "I'd like to add tags for this transaction...", "Events.revealTags()" 4 | 5 | %fieldset#tags{:style => event_has_tags? ? nil : "display: none"} 6 | %legend Tags 7 | 8 | %p 9 | Tags for this transaction: 10 | %br/ 11 | = text_field_tag "event[tags][list]", tag_list_for_event, :style => "width: 95%" 12 | %br/ 13 | %small Enter tag names, separated by commas, that will apply to this entire transaction. 14 | 15 | #event_tags_list_select.tag_select{:style => "display: none"} 16 | :javascript 17 | Events.autocomplete_tag_field('event_tags_list', {tokens: ','}); 18 | 19 | %p#tag_items_collapsed{:style => event_has_partial_tags? ? "display: none" : nil} 20 | → 21 | = link_to_function "I'd like to add tags for parts of this transaction...", "Events.revealPartialTags()" 22 | 23 | #tag_items{:style => event_has_partial_tags? ? nil : "display: none"} 24 | %p Specify an amount for each tag. That amount will then be marked by that tag. 25 | 26 | %ol#tagged_items 27 | - for_each_partial_tagged_item do |item| 28 | %li= render :partial => "events/tagged_item", :locals => { :tagged_item => item } 29 | 30 | %p= link_to_function "More tags, please!", "Events.addTaggedItem()" 31 | -------------------------------------------------------------------------------- /app/models/bucket.rb: -------------------------------------------------------------------------------- 1 | class Bucket < ActiveRecord::Base 2 | Temp = Struct.new(:id, :name, :role, :balance) 3 | 4 | belongs_to :account 5 | belongs_to :author, :class_name => "User", :foreign_key => "user_id" 6 | 7 | has_many :line_items 8 | 9 | attr_accessible :name, :role 10 | 11 | validates_presence_of :name 12 | validates_uniqueness_of :name, :scope => :account_id, :case_sensitive => false 13 | 14 | def self.default 15 | Temp.new("r:default", "General", "default", 0) 16 | end 17 | 18 | def self.aside 19 | Temp.new("r:aside", "Aside", "aside", 0) 20 | end 21 | 22 | def self.template 23 | new :name => "Bucket name (e.g. Groceries)", 24 | :role => "aside | default | nil" 25 | end 26 | 27 | def assimilate(bucket) 28 | if bucket == self 29 | raise ArgumentError, "cannot assimilate self" 30 | end 31 | 32 | if bucket.account_id != account_id 33 | raise ArgumentError, "cannot assimilate bucket from different account" 34 | end 35 | 36 | old_id = bucket.id 37 | 38 | Bucket.transaction do 39 | LineItem.update_all(["bucket_id = ?", id], :bucket_id => old_id) 40 | update_attribute :balance, balance + bucket.balance 41 | bucket.destroy 42 | end 43 | end 44 | 45 | def to_xml(options={}) 46 | options[:only] = %w(name role) if new_record? 47 | super(options) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /app/views/events/_form_general.html.haml: -------------------------------------------------------------------------------- 1 | %fieldset#general_information 2 | %legend General Information 3 | 4 | %p 5 | %span.expense_label When did this expense occur? 6 | %span.deposit_label When did this deposit occur? 7 | %span.transfer_label When did this transfer occur? 8 | = form.calendar_date_select :occurred_on, :size => 10 9 | 10 | %p 11 | %span.expense_label How much was paid? 12 | %span.deposit_label How much was deposited? 13 | %span.transfer_label How much was transferred? 14 | == $#{text_field_tag :amount, event_amount_value, :size => 8, :id => "expense_total", :class => "number", :onchange => "Events.updateUnassigned()"} 15 | 16 | %p 17 | %span.expense_label Who received the payment? 18 | %span.deposit_label Where did this deposit come from? 19 | %span.transfer_label What was this transfer for? 20 | = form.text_field :actor, :size => 30 21 | 22 | %p#memo_link{:style => visible?(!event_wants_memo?)} 23 | Got more to say? 24 | = link_to_function "Add a more verbose description...", "Events.revealMemo()" 25 | 26 | %p#memo{:style => visible?(event_wants_memo?)} 27 | Describe this transaction: 28 | %br 29 | = form.text_area :memo, :rows => 2, :cols => 70 30 | -------------------------------------------------------------------------------- /app/controllers/statements_controller.rb: -------------------------------------------------------------------------------- 1 | class StatementsController < ApplicationController 2 | before_filter :find_account, :only => %w(index new create) 3 | before_filter :find_statement, :only => %w(show edit update destroy) 4 | 5 | def index 6 | @statements = account.statements.balanced 7 | end 8 | 9 | def new 10 | @statement = account.statements.build(:ending_balance => account.balance, 11 | :occurred_on => Date.today) 12 | end 13 | 14 | def create 15 | @statement = account.statements.create(params[:statement]) 16 | redirect_to(edit_statement_url(@statement)) 17 | end 18 | 19 | def show 20 | end 21 | 22 | def edit 23 | @uncleared = account.account_items.uncleared(:with => statement, :include => :event) 24 | end 25 | 26 | def update 27 | statement.update_attributes(params[:statement]) 28 | redirect_to(account) 29 | end 30 | 31 | def destroy 32 | statement.destroy 33 | redirect_to(account) 34 | end 35 | 36 | protected 37 | 38 | attr_reader :uncleared, :statements 39 | helper_method :uncleared, :statements 40 | 41 | attr_reader :account, :statement 42 | helper_method :account, :statement 43 | 44 | def find_account 45 | @account = Account.find(params[:account_id]) 46 | @subscription = user.subscriptions.find(@account.subscription_id) 47 | end 48 | 49 | def find_statement 50 | @statement = Statement.find(params[:id]) 51 | @account = @statement.account 52 | @subscription = user.subscriptions.find(@account.subscription_id) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/models/tagged_item.rb: -------------------------------------------------------------------------------- 1 | class TaggedItem < ActiveRecord::Base 2 | include OptionHandler, Pageable 3 | 4 | belongs_to :event 5 | belongs_to :tag 6 | 7 | before_create :ensure_consistent_tag, :increment_tag_balance, :ensure_occurred_on 8 | before_destroy :decrement_tag_balance 9 | 10 | attr_accessible :tag, :tag_id, :amount 11 | 12 | def tag_id=(value) 13 | case value 14 | when Fixnum, /^\s*\d+\s*$/ then super(value) 15 | else @tag_to_translate = value 16 | end 17 | end 18 | 19 | def to_xml(options={}) 20 | options[:except] = Array(options[:except]) 21 | options[:except].concat [:event_id, :occurred_on] 22 | options[:except] << :tag_id unless new_record? 23 | 24 | append_to_options(options, :include, :tag => { :except => :subscription_id }) 25 | super(options) 26 | end 27 | 28 | protected 29 | 30 | def ensure_consistent_tag 31 | if @tag_to_translate =~ /^n:(.*)/ 32 | self.tag_id = event.subscription.tags.find_or_create_by_name($1).id 33 | else 34 | # make sure the given tag id exists in the given subscription 35 | event.subscription.tags.find(tag_id) 36 | end 37 | end 38 | 39 | def ensure_occurred_on 40 | self.occurred_on ||= event.occurred_on 41 | end 42 | 43 | def increment_tag_balance 44 | Tag.connection.update "UPDATE tags SET balance = balance + #{amount} WHERE id = #{tag_id}" 45 | end 46 | 47 | def decrement_tag_balance 48 | Tag.connection.update "UPDATE tags SET balance = balance - #{amount} WHERE id = #{tag_id}" 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/functional/sessions_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class SessionsControllerTest < ActionController::TestCase 4 | test "new should render login page" do 5 | get :new 6 | assert_response :success 7 | assert_template "sessions/new" 8 | end 9 | 10 | test "create should redirect to login page on bad user name" do 11 | post :create, :user_name => "jimjim", :password => "whatever" 12 | assert_redirected_to new_session_url 13 | assert @response.session[:user_id].blank? 14 | end 15 | 16 | test "create should redirect to login page on bad password" do 17 | post :create, :user_name => "john", :password => "whatever" 18 | assert_redirected_to new_session_url 19 | assert @response.session[:user_id].blank? 20 | end 21 | 22 | test "create should redirect to subscription page on success when only one subscription" do 23 | post :create, :user_name => "ttaylor", :password => "testing" 24 | assert_redirected_to subscription_url(subscriptions(:tim)) 25 | assert_equal users(:tim).id, @response.session[:user_id] 26 | end 27 | 28 | test "create should redirect to subscription index on success when multiple subscriptions" do 29 | post :create, :user_name => "jjohnson", :password => "testing" 30 | assert_redirected_to subscriptions_url 31 | assert_equal users(:john).id, @response.session[:user_id] 32 | end 33 | 34 | test "destroy should remove user_id from session" do 35 | login! :john 36 | 37 | get :destroy 38 | assert_redirected_to new_session_url 39 | assert @response.session[:user_id].blank? 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/models/line_item.rb: -------------------------------------------------------------------------------- 1 | class LineItem < ActiveRecord::Base 2 | include Pageable 3 | 4 | VALID_ROLES = %w(payment_source 5 | credit_options 6 | transfer_to 7 | transfer_from 8 | deposit 9 | reallocate_to 10 | reallocate_from 11 | aside 12 | primary) 13 | 14 | # 'primary' role is omitted, as it is something of a special case 15 | VALID_ROLE_GROUPS = { 16 | "payment_source" => %w(payment_source credit_options aside), 17 | "credit_options" => %w(payment_source credit_options aside), 18 | "transfer_to" => %w(transfer_from transfer_to), 19 | "transfer_from" => %w(transfer_from transfer_to), 20 | "deposit" => %w(deposit), 21 | "reallocate_to" => %w(primary reallocate_to), 22 | "reallocate_from" => %w(reallocate_from primary), 23 | "aside" => %w(payment_source credit_options aside) 24 | } 25 | 26 | belongs_to :event 27 | belongs_to :account 28 | belongs_to :bucket 29 | 30 | after_create :increment_bucket_balance 31 | before_destroy :decrement_bucket_balance 32 | 33 | def to_xml(options={}) 34 | options[:except] = Array(options[:except]) 35 | options[:except].concat [:event_id, :occurred_on, :id] 36 | super(options) 37 | end 38 | 39 | protected 40 | 41 | def increment_bucket_balance 42 | bucket.update_attribute :balance, bucket.balance + amount 43 | end 44 | 45 | def decrement_bucket_balance 46 | bucket.update_attribute :balance, bucket.balance - amount 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /public/javascripts/accounts.js: -------------------------------------------------------------------------------- 1 | var Accounts = { 2 | revealForm: function() { 3 | if($('blankslate')) { 4 | $('blankslate').hide(); 5 | } else { 6 | $('data').hide(); 7 | $('links').hide(); 8 | } 9 | 10 | $('new_account').show(); 11 | $('account_name').activate(); 12 | }, 13 | 14 | hideForm: function() { 15 | if(Accounts.origin) { 16 | window.location = Accounts.origin; 17 | } else { 18 | Accounts.reset(); 19 | $('new_account').hide(); 20 | 21 | if($('blankslate')) { 22 | $('blankslate').show(); 23 | } else { 24 | $('data').show(); 25 | $('links').show(); 26 | } 27 | } 28 | }, 29 | 30 | submit: function() { 31 | if($F('account_name').blank()) { 32 | $('account_name').activate(); 33 | alert('Please provide a name for the account.'); 34 | return false; 35 | } 36 | 37 | var balance = Money.parse('current_balance', true); 38 | $('account_starting_balance_amount').value = balance; 39 | 40 | return true; 41 | }, 42 | 43 | reset: function() { 44 | $('new_account_form').reset(); 45 | }, 46 | 47 | rename: function(url, name, token) { 48 | new_name = prompt("Enter the name for this account:", name); 49 | if(new_name && new_name != name) { 50 | params = encodeURIComponent("account[name]") + "=" + encodeURIComponent(new_name) + 51 | "&authenticity_token=" + encodeURIComponent(token); 52 | 53 | new Ajax.Request(url, { 54 | asynchronous:true, 55 | evalScripts:true, 56 | method:'put', 57 | parameters:params 58 | }); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/models/subscription.rb: -------------------------------------------------------------------------------- 1 | class Subscription < ActiveRecord::Base 2 | DEFAULT_PAGE_SIZE = 5 3 | 4 | belongs_to :owner, :class_name => "User" 5 | 6 | has_many :accounts 7 | has_many :tags 8 | 9 | has_many :events do 10 | def recent(n=0, options={}) 11 | size = (options[:size] || DEFAULT_PAGE_SIZE).to_i 12 | n = n.to_i 13 | 14 | records = find(:all, :include => :account_items, 15 | :order => "created_at DESC", 16 | :limit => size + 1, 17 | :offset => n * size) 18 | 19 | [records.length > size, records[0,size]] 20 | end 21 | 22 | def prepare(attrs={}) 23 | event = build(:role => attrs[:role], :occurred_on => Date.today) 24 | 25 | case event.role 26 | when :reallocation 27 | event.actor = "Bucket reallocation" 28 | if attrs[:from] 29 | bucket = Bucket.find(attrs[:from]) 30 | account = @owner.accounts.find(bucket.account_id) 31 | event.line_items.build(:role => "primary", :account => account, :bucket => bucket) 32 | event.line_items.build(:role => "reallocate_from", :account => account, :bucket => account.buckets.default) 33 | elsif attrs[:to] 34 | bucket = Bucket.find(attrs[:to]) 35 | account = @owner.accounts.find(bucket.account_id) 36 | event.line_items.build(:role => "primary", :account => account, :bucket => bucket) 37 | event.line_items.build(:role => "reallocate_to", :account => account, :bucket => account.buckets.default) 38 | end 39 | end 40 | 41 | return event 42 | end 43 | end 44 | 45 | has_many :user_subscriptions 46 | has_many :users, :through => :user_subscriptions 47 | end 48 | -------------------------------------------------------------------------------- /app/views/events/_form_section.html.haml: -------------------------------------------------------------------------------- 1 | %fieldset{:id => section, :style => section_visible_for_event?(section) ? nil : "display: none"} 2 | %legend= title 3 | 4 | %p 5 | = account_prompt 6 | = select_account(section, accounts, selected_account && selected_account.id) 7 | - if section_wants_check_options?(section) 8 | %span{:id => "#{section}.check_options", :style => visible?(check_options_visible_for?(section))} 9 | == Check ##{form.text_field :check_number, :size => 6} 10 | - if section_wants_repayment_options?(section) 11 | %span.repayment_options{:id => "#{section}.repayment_options", :style => visible?(repayment_options_visible_for?(section))} 12 | = "→ " + link_to_function("Specify repayment options", "Events.showRepaymentOptions('#{section}')") 13 | 14 | - if section_has_single_bucket?(section) 15 | %p{:id => "#{section}.single_bucket"} 16 | = single_bucket_prompt 17 | = select_bucket(section, :line_item => line_item_for_section(section)) 18 | - if section == :credit_options 19 | %br/ 20 | %small Money from this bucket will be transferred to an "Aside" bucket, to be used later to pay off this credit. 21 | 22 | %div{:id => "#{section}.multiple_buckets", :style => multi_bucket_visibility_for(section)} 23 | %p 24 | = multi_bucket_prompt 25 | %span{:id => "#{section}.unassigned"} 26 | %ol{:id => "#{section}.line_items"} 27 | - for_each_line_item_in(section) do |item| 28 | %li= render :partial => "events/line_item", :object => item, :locals => { :section => section } 29 | %p= link_to_function "More buckets, please!", "Events.addLineItemTo('#{section}', true)" 30 | -------------------------------------------------------------------------------- /app/models/account_item.rb: -------------------------------------------------------------------------------- 1 | # AccountItems are almost the same as LineItems. However, a single Event may have 2 | # multiple LineItems associated with the same account (think "split transactions"), 3 | # and querying and aggregating those to show activity in an account would be more 4 | # work than it really needs to be. AccountItems are basically an optimization that 5 | # lets us show that activity more easily; each event will have exactly one AccountItem 6 | # record for each Account that the Event references. 7 | 8 | class AccountItem < ActiveRecord::Base 9 | include Pageable 10 | 11 | belongs_to :event 12 | belongs_to :account 13 | belongs_to :statement 14 | 15 | after_create :increment_balance 16 | before_destroy :decrement_balance 17 | 18 | named_scope :uncleared, lambda { |*args| AccountItem.options_for_uncleared(*args) } 19 | 20 | def self.options_for_uncleared(*args) 21 | raise ArgumentError, "too many arguments #{args.length} for 1" if args.length > 1 22 | 23 | options = args.first || {} 24 | raise ArgumentError, "expected Hash, got #{options.class}" unless options.is_a?(Hash) 25 | options = options.dup 26 | 27 | conditions = "statement_id IS NULL" 28 | parameters = [] 29 | 30 | if options[:with] 31 | conditions = "(#{conditions} OR statement_id = ?)" 32 | parameters << options[:with] 33 | end 34 | 35 | { :conditions => [conditions, *parameters], :include => options[:include] } 36 | end 37 | 38 | protected 39 | 40 | def increment_balance 41 | account.update_attribute :balance, account.balance + amount 42 | end 43 | 44 | def decrement_balance 45 | account.update_attribute :balance, account.balance - amount 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------- 2 | KNOWN ISSUES 3 | -------------------------------------------------------------------------- 4 | 5 | * After adding an event with a new tag, the tag autocompletion won't pick up the new tag until the page is reloaded. 6 | * Merging one bucket into another can result in events having two line-items referencing the same bucket. 7 | 8 | -------------------------------------------------------------------------- 9 | FEATURES that would be nice to have someday (in no particular order) 10 | -------------------------------------------------------------------------- 11 | 12 | * spinner for ajax actions 13 | * user info page (for password change) 14 | * password reset from login page 15 | * signup process 16 | * exception notifier (?) 17 | * better /subscriptions index 18 | * better 404 and 500 error pages 19 | * searching 20 | * reporting 21 | * transaction templates ("saved" or "memorized" transactions) 22 | * scheduled transactions (occur automatically at specified intervals) 23 | * print stylesheet 24 | * oauth authentication for API 25 | * filter event views to show only events with a non-zero balance for the account in question 26 | * filter event views to show only deposits, or only expenses 27 | * normalize 'actor' so we can do actor-centric queries more efficiently 28 | * autocomplete actor field in event form 29 | * full-text searching on the memo field 30 | * show bucket and account balances next to names in drop-downs 31 | * make bucket reallocation work from bucket perma 32 | * support for multiple 'aside' buckets in a single account 33 | * graphical icons to replace the textual icons for various actions 34 | * add/edit transactions from the reconciliation view 35 | * statement API 36 | -------------------------------------------------------------------------------- /public/javascripts/tags.js: -------------------------------------------------------------------------------- 1 | var Tags = { 2 | rename: function(url, name, token) { 3 | new_name = prompt("Enter the name for this tag:", name); 4 | if(new_name && new_name != name) { 5 | params = encodeURIComponent("tag[name]") + "=" + encodeURIComponent(new_name) + 6 | "&authenticity_token=" + encodeURIComponent(token); 7 | 8 | new Ajax.Request(url, { 9 | asynchronous:true, 10 | evalScripts:true, 11 | method:'put', 12 | parameters:params 13 | }); 14 | } 15 | }, 16 | 17 | deleteTag: function() { 18 | if($('delete_form').down('fieldset')) { 19 | $('delete_form').show(); 20 | $('data').hide(); 21 | } else if(confirm("Are you sure want to delete this tag?")) { 22 | $('delete_form').down('form').submit(); 23 | } 24 | }, 25 | 26 | confirmDelete: function() { 27 | if($('mergeTagOption').down('input').checked) { 28 | if($('receiver_id').selectedIndex <= 0) { 29 | alert("If you want to merge tags, you must select a tag to merge with."); 30 | $('receiver_id').focus(); 31 | return false; 32 | } 33 | } else { 34 | $('receiver_id').selectedIndex = 0; 35 | } 36 | 37 | return confirm("Are you sure you want to delete this tag?"); 38 | }, 39 | 40 | cancelDelete: function() { 41 | $('delete_form').down('form').reset(); 42 | Tags.selectDeleteTag(); 43 | $('delete_form').hide(); 44 | $('data').show(); 45 | }, 46 | 47 | selectDeleteTag: function() { 48 | $('receiver_id').selectedIndex = 0; 49 | $('deleteTagOption').addClassName('selected'); 50 | $('mergeTagOption').removeClassName('selected'); 51 | }, 52 | 53 | selectMergeTag: function() { 54 | $('mergeTagOption').addClassName('selected'); 55 | $('deleteTagOption').removeClassName('selected'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/unit/tag_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TagTest < ActiveSupport::TestCase 4 | test "assimilate should raise exception when argument is same as self" do 5 | assert_no_difference "Tag.count" do 6 | assert_raise(ActiveRecord::RecordNotSaved) do 7 | tags(:john_lunch).assimilate(tags(:john_lunch)) 8 | end 9 | end 10 | 11 | assert_equal tags(:john_lunch), tagged_items(:john_lunch_lunch).tag 12 | end 13 | 14 | test "assimilate should update all tagged items of argument to refer to self" do 15 | items = tags(:john_lunch).tagged_items 16 | assert items.any? 17 | 18 | tags(:john_fuel).assimilate(tags(:john_lunch)) 19 | assert items.all? { |item| item.reload.tag == tags(:john_fuel) } 20 | end 21 | 22 | test "assimilate should update balance to include amounts of tagged items" do 23 | balance = tags(:john_fuel).balance 24 | tags(:john_fuel).assimilate(tags(:john_lunch)) 25 | 26 | assert_equal balance + tags(:john_lunch).balance, tags(:john_fuel).balance 27 | assert_equal balance + tags(:john_lunch).balance, tags(:john_fuel, :reload).balance 28 | end 29 | 30 | test "assimilate should destroy argument but not tagged items" do 31 | items = tags(:john_lunch).tagged_items 32 | assert items.any? 33 | 34 | tags(:john_fuel).assimilate(tags(:john_lunch)) 35 | assert !Tag.exists?(tags(:john_lunch).id) 36 | assert items.all? { |item| TaggedItem.exists?(item.id) } 37 | end 38 | 39 | test "duplicates should be allowed for different subscriptions" do 40 | assert_difference "Tag.count" do 41 | subscriptions(:tim).tags.create(:name => "lunch") 42 | end 43 | end 44 | 45 | test "duplicates should be forbidden within a subscription" do 46 | assert_no_difference "Tag.count" do 47 | tag = subscriptions(:john).tags.create(:name => "lunch") 48 | assert tag.errors.on(:name) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/views/accounts/_form.html.haml: -------------------------------------------------------------------------------- 1 | - form_for(:account, @account, :url => subscription_accounts_path(subscription), :html => { :onsubmit => "if(!Accounts.submit()) return false", :id => "new_account_form" }) do |form| 2 | - if form.object && !form.object.errors.empty? 3 | %fieldset.errors 4 | %legend Errors 5 | 6 | %p The account could not be saved because of the following error(s): 7 | %ul 8 | - form.object.errors.each_full do |message| 9 | %li&= message 10 | 11 | %fieldset 12 | %legend Account info 13 | 14 | %p 15 | %label{:for => "account_name"} Choose a name for this account: 16 | = form.text_field :name, :class => "text" 17 | 18 | %p 19 | %label 20 | What kind of account is this? 21 | = form.select :role, [["Checking", "checking"], ["Credit card", "credit-card"], ["Other", "other"]] 22 | 23 | - if form.object.nil? || form.object.new_record? 24 | %fieldset 25 | %legend Starting balance 26 | 27 | .instructions 28 | %p Take a look at your latest statement for this account. 29 | %p Note the current balance from the statement, as | 30 | well as the date the statement was issued, and enter | 31 | them below. | 32 | = hidden_field_tag "account[starting_balance][amount]", "" 33 | 34 | %p 35 | %label 36 | What is the current balance: 37 | == $#{text_field_tag :current_balance, account_starting_balance_amount, :class => "number", :size => 8} 38 | 39 | %p 40 | %label 41 | When was the statement issued: 42 | = calendar_date_select_tag "account[starting_balance][occurred_on]", account_starting_balance_occurred_on, :size => 9 43 | 44 | %p 45 | = submit_tag "Create this account" 46 | or 47 | = link_to_function "cancel", "Accounts.hideForm()" 48 | -------------------------------------------------------------------------------- /test/unit/user_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UserTest < ActiveSupport::TestCase 4 | test "authenticate should return user if password is correct" do 5 | assert_equal users(:john), User.authenticate("jjohnson", "testing") 6 | end 7 | 8 | test "authenticate should return nil if user_name is incorrect" do 9 | assert_nil User.authenticate("john", "testing") 10 | end 11 | 12 | test "authenticate should return nil if password is incorrect" do 13 | assert_nil User.authenticate("jjohnson", "test") 14 | end 15 | 16 | test "creating new user should set salt and hash password" do 17 | user = subscriptions(:john).users.create(:name => "Tom Thompson", 18 | :email => "tthompson@domain.test", :user_name => "tthompson", 19 | :password => "thePassword") 20 | assert !user.password_hash.blank? 21 | assert !user.salt.blank? 22 | assert_equal user, User.authenticate("tthompson", "thePassword") 23 | end 24 | 25 | test "updating user's password should change salt and hash new password" do 26 | old_salt = users(:john).salt 27 | old_password_hash = users(:john).password_hash 28 | 29 | users(:john).update_attribute :password, "vamoose!" 30 | assert_not_equal users(:john).salt, old_salt 31 | assert_not_equal users(:john).password_hash, old_password_hash 32 | 33 | assert_nil User.authenticate("jjohnson", "testing") 34 | assert_equal users(:john), User.authenticate("jjohnson", "vamoose!") 35 | end 36 | 37 | test "creating new user with duplicate user name should fail" do 38 | begin 39 | subscriptions(:john).users.create(:name => "James Johnson", 40 | :email => "james.johnson@domain.test", :user_name => "jjohnson", 41 | :password => "ponies!") 42 | rescue ActiveRecord::RecordInvalid => error 43 | assert error.record.errors.on(:user_name) 44 | else 45 | flunk "expected create to fail" 46 | end 47 | end 48 | 49 | test "user as xml should not emit password_hash or salt" do 50 | xml = users(:john).to_xml 51 | user = Hash.from_xml(xml)["user"] 52 | assert !user.key?("password_hash") 53 | assert !user.key?("salt") 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /public/javascripts/buckets.js: -------------------------------------------------------------------------------- 1 | var Buckets = { 2 | onMouseOver: function(id) { 3 | var nubbin = $('nubbin_bucket_' + id); 4 | var offset = nubbin.up("tr").cumulativeOffset(); 5 | 6 | nubbin.show(); 7 | nubbin.style.left = (offset.left - nubbin.getWidth()) + "px"; 8 | }, 9 | 10 | onMouseOut: function(id) { 11 | $('nubbin_bucket_' + id).hide(); 12 | }, 13 | 14 | configureEvent: function() { 15 | Events.defaultDate = new Date().toFormattedString(); 16 | Events.defaultActor = "Bucket reallocation"; 17 | }, 18 | 19 | transferTo: function(account_id, bucket_id) { 20 | if(Buckets.newEventUrl) { 21 | window.location = Buckets.newEventUrl + "?role=reallocation&to=" + bucket_id; 22 | } else { 23 | Buckets.configureEvent(); 24 | Events.revealReallocationForm('to', account_id, bucket_id); 25 | } 26 | }, 27 | 28 | transferFrom: function(account_id, bucket_id) { 29 | if(Buckets.newEventUrl) { 30 | window.location = Buckets.newEventUrl + "?role=reallocation&from=" + bucket_id; 31 | } else { 32 | Buckets.configureEvent(); 33 | Events.revealReallocationForm('from', account_id, bucket_id); 34 | } 35 | }, 36 | 37 | view: function() { 38 | if($$('tr.bucket').any()) { 39 | return 'index'; 40 | } else { 41 | return 'perma'; 42 | } 43 | }, 44 | 45 | rename: function(url, name, token) { 46 | new_name = prompt("Enter the name for this bucket:", name); 47 | if(new_name && new_name != name) { 48 | params = encodeURIComponent("bucket[name]") + "=" + encodeURIComponent(new_name) + 49 | "&authenticity_token=" + encodeURIComponent(token) + "&view=" + Buckets.view(); 50 | 51 | new Ajax.Request(url, { 52 | asynchronous:true, 53 | evalScripts:true, 54 | method:'put', 55 | parameters:params 56 | }); 57 | } 58 | }, 59 | 60 | deleteBucket: function() { 61 | $('data').hide(); 62 | $('delete_form').show(); 63 | }, 64 | 65 | confirmDelete: function() { 66 | return confirm("Are you sure you want to delete this bucket?"); 67 | }, 68 | 69 | cancelDelete: function() { 70 | $('delete_form').hide(); 71 | $('data').show(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /test/fixtures/account_items.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------- 2 | # john 3 | # -------------------------------------------------------------- 4 | 5 | john_checking_starting_balance: 6 | event: john_checking_starting_balance 7 | account: john_checking 8 | amount: 100000 9 | occurred_on: <%= 60.days.ago.to_date.to_s(:db) %> 10 | statement: john 11 | 12 | john_lunch_mastercard: 13 | event: john_lunch 14 | account: john_mastercard 15 | amount: -775 16 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %> 17 | 18 | john_lunch_checking: 19 | event: john_lunch 20 | account: john_checking 21 | amount: 0 22 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %> 23 | 24 | john_bill_pay_checking: 25 | event: john_bill_pay 26 | account: john_checking 27 | amount: -775 28 | occurred_on: <%= 58.days.ago.utc.to_s(:db) %> 29 | statement: john 30 | 31 | john_bill_pay_mastercard: 32 | event: john_bill_pay 33 | account: john_mastercard 34 | amount: 775 35 | occurred_on: <%= 58.days.ago.utc.to_s(:db) %> 36 | 37 | john_reallocate_from_checking: 38 | event: john_reallocate_from 39 | account: john_checking 40 | amount: 0 41 | occurred_on: <%= 57.days.ago.utc.to_s(:db) %> 42 | 43 | john_reallocate_to_checking: 44 | event: john_reallocate_to 45 | account: john_checking 46 | amount: 0 47 | occurred_on: <%= 56.days.ago.utc.to_s(:db) %> 48 | 49 | john_lunch_again_mastercard: 50 | event: john_lunch_again 51 | account: john_mastercard 52 | amount: -1025 53 | occurred_on: <%= 55.days.ago.utc.to_s(:db) %> 54 | 55 | john_lunch_again_checking: 56 | event: john_lunch_again 57 | account: john_checking 58 | amount: 0 59 | occurred_on: <%= 55.days.ago.utc.to_s(:db) %> 60 | 61 | john_bare_mastercard: 62 | event: john_bare_mastercard 63 | account: john_mastercard 64 | amount: -1500 65 | occurred_on: <%= 54.days.ago.utc.to_s(:db) %> 66 | 67 | # -------------------------------------------------------------- 68 | # tim 69 | # -------------------------------------------------------------- 70 | 71 | tim_checking_starting_balance: 72 | event: tim_checking_starting_balance 73 | account: tim_checking 74 | amount: 125000 75 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %> 76 | -------------------------------------------------------------------------------- /public/javascripts/money.js: -------------------------------------------------------------------------------- 1 | var Money = { 2 | dollars: function(cents, keepNegative) { 3 | cents = keepNegative ? cents : Math.abs(cents); 4 | return (cents / 100).toFixed(2); 5 | }, 6 | 7 | parse: function(field, keepNegative) { 8 | return Money.parseValue($F(field), keepNegative); 9 | }, 10 | 11 | /* don't want to use parseFloat, because we can be subject to 12 | * floating point round-off */ 13 | parseValue: function(string, keepNegative) { 14 | var value = string.gsub(/[^-+\d.]/, ""); 15 | var match = value.match(/^([-+]?)(\d*)(?:\.(\d*))?$/); 16 | 17 | if(!match) return 0; 18 | 19 | var sign = ((match[1] == "-") && keepNegative) ? -1 : 1; 20 | if(match[2].length > 0) 21 | var dollars = parseInt(match[2]); 22 | else 23 | var dollars = 0; 24 | 25 | if(match[3] && match[3].length > 0) { 26 | var cents_magnitude = Math.pow(10, match[3].length); 27 | var cents_text = match[3].sub(/^0+/, ""); 28 | if(cents_text.blank()) cents_text = "0"; 29 | var cents = Math.round(parseInt(cents_text) * 100 / cents_magnitude); 30 | } else { 31 | var cents = 0; 32 | } 33 | 34 | return sign * (dollars * 100 + cents); 35 | }, 36 | 37 | format: function(field) { 38 | return Money.formatValue(Money.parse(field, true)); 39 | }, 40 | 41 | formatValue: function(cents) { 42 | var sign = cents < 0 ? -1 : 1; 43 | var source = String(Math.abs(cents)); 44 | var result; 45 | 46 | if(source.length > 2) { 47 | result = "." + source.slice(-2); 48 | source = source.slice(0,-2); 49 | } else if(source.length == 2) { 50 | result = "." + source; 51 | source = ""; 52 | } else if(source.length == 1) { 53 | result = ".0" + source; 54 | source = ""; 55 | } else { 56 | result = ".00"; 57 | } 58 | 59 | while(source.length > 3) { 60 | result = "," + source.slice(-3) + result; 61 | source = source.slice(0,-3); 62 | } 63 | 64 | if(source.length > 0) { 65 | result = source + result; 66 | } else if(result[0] == ".") { 67 | result = "0" + result; 68 | } 69 | 70 | if(sign < 0) { 71 | result = "-" + result; 72 | } 73 | 74 | return result; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # Filters added to this controller apply to all controllers in the application. 2 | # Likewise, all the methods added will be available for all controllers. 3 | 4 | class ApplicationController < ActionController::Base 5 | include OptionHandler 6 | 7 | helper :all 8 | 9 | protect_from_forgery 10 | 11 | before_filter :authenticate 12 | 13 | filter_parameter_logging :password 14 | 15 | rescue_from ActiveRecord::RecordNotFound, :with => :render_404 16 | 17 | protected 18 | 19 | attr_reader :subscription, :user 20 | helper_method :subscription, :user 21 | 22 | def find_subscription 23 | @subscription = user.subscriptions.find(params[:subscription_id] || params[:id]) 24 | end 25 | 26 | def authenticate 27 | if session[:user_id] 28 | @user = User.find(session[:user_id]) 29 | elsif via_api? 30 | authenticate_or_request_with_http_basic do |user_name, password| 31 | @user = User.authenticate(user_name, password) 32 | end 33 | else 34 | redirect_to(new_session_url) 35 | end 36 | end 37 | 38 | def current_location 39 | controller_name 40 | end 41 | helper_method :current_location 42 | 43 | def render_404 44 | respond_to do |format| 45 | format.html { render :file => "#{RAILS_ROOT}/public/404.html", :status => :not_found } 46 | format.xml { head :not_found } 47 | end 48 | end 49 | 50 | def via_api? 51 | request.format == Mime::XML 52 | end 53 | helper_method :via_api? 54 | 55 | private 56 | 57 | def self.acceptable_includes(*list) 58 | includes = read_inheritable_attribute(:acceptable_includes) || [] 59 | 60 | if list.any? 61 | includes = Set.new(list.map(&:to_s)) + includes 62 | write_inheritable_attribute(:acceptable_includes, includes) 63 | end 64 | 65 | includes 66 | end 67 | 68 | def acceptable_includes 69 | self.class.acceptable_includes 70 | end 71 | 72 | def eager_options(options={}) 73 | if params[:include] 74 | list = acceptable_includes & params[:include].split(/,/) 75 | append_to_options(options, :include, list.map(&:to_sym)) if list.any? 76 | end 77 | 78 | return options 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/functional/subscriptions_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class SubscriptionsControllerTest < ActionController::TestCase 4 | setup :login_default_user 5 | 6 | test "index should redirect to sole subscription if there is only one" do 7 | login! :tim 8 | get :index 9 | assert_redirected_to subscription_url(subscriptions(:tim)) 10 | end 11 | 12 | test "index should list all subscriptions if there are many" do 13 | get :index 14 | assert_response :success 15 | assert_template "subscriptions/index" 16 | end 17 | 18 | test "index should not include any subscriptions not accessible to user" do 19 | get :index 20 | assert_response :success 21 | assert_select "li#subscription_#{subscriptions(:john).id}" 22 | assert_select "li#subscription_#{subscriptions(:john_family).id}" 23 | assert_select "li#subscription_#{subscriptions(:tim).id}", false 24 | end 25 | 26 | test "show should 404 for invalid subscription" do 27 | assert !Subscription.exists?(1) 28 | get :show, :id => 1 29 | assert_response :missing 30 | end 31 | 32 | test "show should 404 for inaccessible subscription" do 33 | get :show, :id => subscriptions(:tim).id 34 | assert_response :missing 35 | end 36 | 37 | test "show should display dashboard for selected subscription" do 38 | get :show, :id => subscriptions(:john).id 39 | assert_response :success 40 | assert_template "subscriptions/show" 41 | assert_equal subscriptions(:john), assigns(:subscription) 42 | end 43 | 44 | # == API tests ======================================================================== 45 | 46 | test "index via API should return list of all subscriptions available to user" do 47 | get :index, :format => "xml" 48 | assert_response :success 49 | xml = Hash.from_xml(@response.body) 50 | assert xml.key?("subscriptions") 51 | end 52 | 53 | test "show via API should return 404 for inaccessible subscription" do 54 | get :show, :id => subscriptions(:tim).id, :format => "xml" 55 | assert_response :missing 56 | end 57 | 58 | test "show should return requested subscription record" do 59 | get :show, :id => subscriptions(:john).id, :format => "xml" 60 | assert_response :success 61 | xml = Hash.from_xml(@response.body) 62 | assert xml.key?("subscription") 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /public/javascripts/statements.js: -------------------------------------------------------------------------------- 1 | var Statements = { 2 | clickItem: function(id) { 3 | $('check_account_item_' + id).click(); 4 | }, 5 | 6 | toggleCleared: function(id) { 7 | var checked = $('check_account_item_' + id).checked; 8 | var amount = parseInt($('amount_account_item_' + id).innerHTML); 9 | var subtotalField = $('account_item_' + id).up('fieldset').down('.subtotal_dollars'); 10 | var subtotal = Money.parseValue(subtotalField.innerHTML, true); 11 | 12 | if(checked) { 13 | $('account_item_' + id).addClassName('cleared'); 14 | subtotalField.innerHTML = "$" + Money.formatValue(subtotal + amount); 15 | } else { 16 | $('account_item_' + id).removeClassName('cleared'); 17 | subtotalField.innerHTML = "$" + Money.formatValue(subtotal - amount); 18 | } 19 | 20 | Statements.updateBalances(); 21 | }, 22 | 23 | startingBalance: function() { 24 | if(!Statements.cachedBalance) 25 | Statements.cachedBalance = Money.parseValue($('starting_balance').innerHTML, true); 26 | return Statements.cachedBalance; 27 | }, 28 | 29 | endingBalance: function() { 30 | return Money.parse($('statement_ending_balance'), true); 31 | }, 32 | 33 | settled: function() { 34 | return $$('.subtotal_dollars').inject(0, function(sum, span) { 35 | return sum + Money.parseValue(span.innerHTML, true); 36 | }); 37 | }, 38 | 39 | remaining: function() { 40 | return Statements.startingBalance() + Statements.settled() - Statements.endingBalance(); 41 | }, 42 | 43 | updateBalances: function() { 44 | var ending = $('statement_ending_balance'); 45 | ending.value = Money.format(ending); 46 | 47 | var remaining = Statements.remaining(); 48 | var remainingText = Money.formatValue(remaining); 49 | 50 | ['deposits', 'checks', 'expenses'].each(function(section) { 51 | if($(section)) { 52 | var span = $$("#" + section + " .remaining_dollars").first(); 53 | 54 | if(remaining == 0) 55 | span.addClassName("balanced"); 56 | else 57 | span.removeClassName("balanced"); 58 | 59 | span.innerHTML = "$" + remainingText; 60 | } 61 | }) 62 | 63 | if(remaining == 0) { 64 | $('balanced').show(); 65 | $('actions').hide(); 66 | } else { 67 | $('balanced').hide(); 68 | $('actions').show(); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/views/statements/edit.html.haml: -------------------------------------------------------------------------------- 1 | #data.content 2 | %h2== Balance your statement 3 | 4 | .statement.edit.form 5 | 6 | - form_for(statement) do |form| 7 | 8 | %table.general 9 | %tr 10 | %th.occurred_on Statement date 11 | %th.starting_balance Starting balance 12 | %th.ending_balance Ending balance 13 | %tr 14 | %td.occurred_on= form.calendar_date_select :occurred_on, :size => 10 15 | %td#starting_balance.starting_balance.number= format_cents(statement.starting_balance) 16 | %td.ending_balance= "$" + form.text_field(:ending_balance, :size => 8, :class => "number", :value => format_cents(statement.ending_balance, :unit => ""), :onchange => "Statements.updateBalances()") 17 | 18 | - if uncleared.deposits.any? 19 | %fieldset#deposits 20 | %legend Deposits 21 | 22 | .uncleared.deposits 23 | %table= render :partial => "statements/uncleared", :collection => uncleared.deposits 24 | 25 | = render :partial => "statements/subtotal", :object => statement.account_items.deposits.sum(&:amount) 26 | 27 | - if uncleared.checks.any? 28 | %fieldset#checks 29 | %legend Checks 30 | 31 | .uncleared.checks 32 | %table= render :partial => "statements/uncleared", :collection => uncleared.checks 33 | 34 | = render :partial => "statements/subtotal", :object => statement.account_items.checks.sum(&:amount) 35 | 36 | - if uncleared.expenses.any? 37 | %fieldset#expenses 38 | %legend Other expenses 39 | 40 | .uncleared.expenses 41 | %table= render :partial => "statements/uncleared", :collection => uncleared.expenses 42 | 43 | = render :partial => "statements/subtotal", :object => statement.account_items.expenses.sum(&:amount) 44 | 45 | #balanced{:style => visible?(statement.balanced?)} 46 | %h3 Congratulations! 47 | 48 | %p Your records exactly match your account statement, and everything balances. 49 | 50 | %p= form.submit "Close out this statement" 51 | 52 | %p#actions{:style => visible?(!statement.balanced?)} 53 | = form.submit "Save for later" 54 | or 55 | = link_to("abort this reconciliation", statement_path(statement), :method => :delete, :confirm => "Are you sure you want to discard this reconciliation?") 56 | -------------------------------------------------------------------------------- /test/fixtures/buckets.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------- 2 | # john 3 | # -------------------------------------------------------------- 4 | 5 | john_checking_general: 6 | account: john_checking 7 | author: john 8 | name: General 9 | role: default 10 | balance: 98000 11 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 12 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 13 | 14 | john_checking_groceries: 15 | account: john_checking 16 | author: john 17 | name: Groceries 18 | role: ~ 19 | balance: 0 20 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 21 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 22 | 23 | john_checking_household: 24 | account: john_checking 25 | author: john 26 | name: Household 27 | role: ~ 28 | balance: 0 29 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 30 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 31 | 32 | john_checking_dining: 33 | account: john_checking 34 | author: john 35 | name: Dining 36 | role: ~ 37 | balance: 200 38 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 39 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 40 | 41 | john_checking_aside: 42 | account: john_checking 43 | author: john 44 | name: Aside 45 | role: aside 46 | balance: 1025 47 | created_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %> 48 | updated_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %> 49 | 50 | john_mastercard_general: 51 | account: john_mastercard 52 | author: john 53 | name: General 54 | role: default 55 | balance: -2525 56 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 57 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 58 | 59 | john_savings_general: 60 | account: john_savings 61 | author: john 62 | name: General 63 | role: default 64 | balance: 0 65 | created_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %> 66 | updated_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %> 67 | 68 | # -------------------------------------------------------------- 69 | # tim 70 | # -------------------------------------------------------------- 71 | 72 | tim_checking_general: 73 | account: tim_checking 74 | author: tim 75 | name: General 76 | role: default 77 | balance: 125000 78 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 79 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 80 | 81 | -------------------------------------------------------------------------------- /test/unit/bucket_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BucketTest < ActiveSupport::TestCase 4 | test "assimilate should change all references of argument to self and destroy argument" do 5 | groceries = buckets(:john_checking_groceries) 6 | dining = buckets(:john_checking_dining) 7 | old_groceries_balance = groceries.balance 8 | old_dining_balance = dining.balance 9 | 10 | groceries.assimilate(dining) 11 | 12 | assert !Bucket.exists?(dining.id) 13 | assert_equal groceries, line_items(:john_lunch_checking_dining).bucket 14 | assert_equal buckets(:john_checking_groceries, :reload).balance, old_groceries_balance + old_dining_balance 15 | end 16 | 17 | test "assimilate bucket from different account should raise exception and make no change" do 18 | groceries = buckets(:john_checking_groceries) 19 | general = buckets(:john_mastercard_general) 20 | 21 | assert_raise ArgumentError do 22 | groceries.assimilate(general) 23 | end 24 | 25 | assert Bucket.exists?(general.id) 26 | assert_equal general, line_items(:john_lunch_mastercard).bucket 27 | end 28 | 29 | test "assimilate self should raise exception and make no change" do 30 | groceries = buckets(:john_checking_groceries) 31 | 32 | assert_no_difference "Bucket.count" do 33 | assert_raise ArgumentError do 34 | groceries.assimilate(groceries) 35 | end 36 | end 37 | end 38 | 39 | test "blank names should be disallowed" do 40 | assert_no_difference "Bucket.count" do 41 | bucket = accounts(:john_checking).buckets.create( 42 | { :name => "", :role => "" }, 43 | :author => users(:john)) 44 | 45 | assert bucket.errors.on(:name) 46 | end 47 | end 48 | 49 | test "duplicate names are allowed for different accounts" do 50 | assert_difference "Bucket.count" do 51 | bucket = accounts(:john_savings).buckets.create( 52 | { :name => buckets(:john_checking_dining).name, :role => "" }, 53 | :author => users(:john)) 54 | 55 | assert bucket.errors.on(:name).blank? 56 | end 57 | end 58 | 59 | test "duplicate names are disallowed within the same account" do 60 | assert_no_difference "Bucket.count" do 61 | bucket = accounts(:john_checking).buckets.create( 62 | { :name => buckets(:john_checking_dining).name, :role => "" }, 63 | :author => users(:john)) 64 | 65 | assert bucket.errors.on(:name) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /app/controllers/accounts_controller.rb: -------------------------------------------------------------------------------- 1 | class AccountsController < ApplicationController 2 | acceptable_includes :author, :buckets 3 | 4 | before_filter :find_account, :except => %w(index create new) 5 | before_filter :find_subscription, :only => %w(index create new) 6 | 7 | def index 8 | respond_to do |format| 9 | format.xml { render :xml => subscription.accounts.to_xml(eager_options(:root => "accounts")) } 10 | end 11 | end 12 | 13 | def show 14 | respond_to do |format| 15 | format.html do 16 | @page = (params[:page] || 0).to_i 17 | @more_pages, @items = account.account_items.page(@page) 18 | end 19 | 20 | format.xml { render :xml => account.to_xml(eager_options) } 21 | end 22 | end 23 | 24 | def new 25 | respond_to do |format| 26 | format.html 27 | format.xml { render :xml => Account.template.to_xml } 28 | end 29 | end 30 | 31 | def create 32 | @account = subscription.accounts.create!(params[:account], :author => user) 33 | respond_to do |format| 34 | format.html { redirect_to(subscription_url(subscription)) } 35 | format.xml { render :xml => @account.to_xml, :status => :created, :location => account_url(@account) } 36 | end 37 | rescue ActiveRecord::RecordInvalid => error 38 | @account = error.record 39 | respond_to do |format| 40 | format.html { render :action => "new" } 41 | format.xml { render :status => :unprocessable_entity, :xml => @account.errors.to_xml } 42 | end 43 | end 44 | 45 | def destroy 46 | account.destroy 47 | respond_to do |format| 48 | format.html { redirect_to(subscription_url(subscription)) } 49 | format.xml { head :ok } 50 | end 51 | end 52 | 53 | def update 54 | account.update_attributes!(params[:account]) 55 | 56 | respond_to do |format| 57 | format.js 58 | format.xml { render :xml => account.to_xml } 59 | end 60 | rescue ActiveRecord::RecordInvalid 61 | respond_to do |format| 62 | format.js 63 | format.xml { render :status => :unprocessable_entity, :xml => account.errors.to_xml } 64 | end 65 | end 66 | 67 | protected 68 | 69 | attr_reader :account 70 | helper_method :account 71 | 72 | def find_account 73 | @account = Account.find(params[:id]) 74 | @subscription = user.subscriptions.find(@account.subscription_id) 75 | end 76 | 77 | def current_location 78 | if account 79 | "accounts/%d" % account.id 80 | else 81 | super 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /app/controllers/tags_controller.rb: -------------------------------------------------------------------------------- 1 | class TagsController < ApplicationController 2 | before_filter :find_subscription, :only => %w(index new create) 3 | before_filter :find_tag, :except => %w(index new create) 4 | 5 | def index 6 | respond_to do |format| 7 | format.xml { render :xml => subscription.tags.to_xml(:root => "tags") } 8 | end 9 | end 10 | 11 | def show 12 | respond_to do |format| 13 | format.html do 14 | @page = (params[:page] || 0).to_i 15 | @more_pages, @items = tag_ref.tagged_items.page(@page) 16 | end 17 | format.xml { render :xml => tag_ref } 18 | end 19 | end 20 | 21 | def new 22 | respond_to { |format| format.xml { render :xml => Tag.template } } 23 | end 24 | 25 | def create 26 | respond_to do |format| 27 | format.xml do 28 | @tag_ref = subscription.tags.create!(params[:tag]) 29 | render :xml => @tag_ref, :status => :created, :location => tag_url(@tag_ref) 30 | end 31 | end 32 | rescue ActiveRecord::RecordInvalid => error 33 | respond_to do |format| 34 | format.xml { render :status => :unprocessable_entity, :xml => error.record.errors } 35 | end 36 | end 37 | 38 | def update 39 | tag_ref.update_attributes!(params[:tag]) 40 | respond_to do |format| 41 | format.js 42 | format.xml { render :xml => tag_ref } 43 | end 44 | rescue ActiveRecord::RecordInvalid 45 | respond_to do |format| 46 | format.js 47 | format.xml { render :status => :unprocessable_entity, :xml => tag_ref.errors } 48 | end 49 | end 50 | 51 | def destroy 52 | if params[:receiver_id].present? 53 | receiver = subscription.tags.find(params[:receiver_id]) 54 | receiver.assimilate(tag_ref) 55 | else 56 | tag_ref.destroy 57 | end 58 | 59 | respond_to do |format| 60 | format.html { redirect_to(receiver || subscription) } 61 | format.xml { head :ok } 62 | end 63 | rescue ActiveRecord::RecordNotSaved => error 64 | head :unprocessable_entity 65 | end 66 | 67 | protected 68 | 69 | # can't call it 'tag' because that conflicts with the Rails 'tag()' 70 | # helper method. 'tag_ref' is lame, but sufficient. 71 | attr_reader :tag_ref 72 | helper_method :tag_ref 73 | 74 | def find_tag 75 | @tag_ref = Tag.find(params[:id]) 76 | @subscription = user.subscriptions.find(@tag_ref.subscription_id) 77 | end 78 | 79 | def current_location 80 | if tag_ref 81 | "tags/%d" % tag_ref.id 82 | else 83 | super 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/tasks/user.rake: -------------------------------------------------------------------------------- 1 | namespace :user do 2 | desc "Create a new user." 3 | task :create => :environment do 4 | require 'highline' 5 | 6 | ui = HighLine.new 7 | 8 | name = ui.ask("Name: ") 9 | email = ui.ask("E-mail: ") 10 | user_name = ui.ask("User name: ") 11 | password = ui.ask("Password: ") 12 | 13 | user = User.create(:name => name, :email => email, 14 | :user_name => user_name, :password => password) 15 | 16 | puts "User `#{user_name}' created: ##{user.id}" 17 | end 18 | 19 | desc "List users (PAGE env var selects which page of users)" 20 | task :list => :environment do 21 | page = ENV['PAGE'].to_i 22 | 23 | users = User.find(:all, :limit => 25, :offset => page * 25, :order => :user_name) 24 | 25 | puts "page ##{page}" 26 | puts "---------------" 27 | 28 | if users.empty? 29 | puts "no users found" 30 | else 31 | users.each do |user| 32 | puts "##{user.id}: \"#{user.name}\" <#{user.email}>" 33 | end 34 | end 35 | end 36 | 37 | desc "Report info about particular user (USERNAME env var)." 38 | task :show => :environment do 39 | user = User.find_by_user_name(ENV['USERNAME']) 40 | 41 | if user 42 | puts "##{user.id}: \"#{user.name}\" <#{user.email}>" 43 | else 44 | puts "No user with that user name." 45 | end 46 | end 47 | 48 | desc "List all subscriptions for the given user (USER_ID env var)" 49 | task :subscriptions => :environment do 50 | user = User.find(ENV['USER_ID']) 51 | 52 | if user.subscriptions.empty? 53 | puts "No subscriptions for `#{user.user_name}' ##{user.id}" 54 | else 55 | puts "Subscriptions for `#{user.user_name}' ##{user.id}" 56 | user.subscriptions.each do |sub| 57 | puts "##{sub.id}" 58 | end 59 | end 60 | end 61 | 62 | desc "Grant access to a specific subscription id (USER_ID env var, SUBSCRIPTION_ID env var)." 63 | task :grant => :environment do 64 | subscription = Subscription.find(ENV['SUBSCRIPTION_ID']) 65 | user = User.find(ENV['USER_ID']) 66 | user.subscriptions << subscription 67 | puts "user `#{user.user_name}' granted access to subscription ##{subscription.id}" 68 | end 69 | 70 | desc "Revoke access to a specific subscription id (USER_ID env var, SUBSCRIPTION_ID env var)." 71 | task :revoke => :environment do 72 | subscription = Subscription.find(ENV['SUBSCRIPTION_ID']) 73 | user = User.find(ENV['USER_ID']) 74 | user.subscriptions.delete(subscription) 75 | puts "user `#{user.user_name}' revoked access to subscription ##{subscription.id}" 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /app/models/statement.rb: -------------------------------------------------------------------------------- 1 | class Statement < ActiveRecord::Base 2 | belongs_to :account 3 | has_many :account_items, :extend => CategorizedItems, :dependent => :nullify 4 | 5 | before_create :initialize_starting_balance 6 | after_save :associate_account_items_with_self 7 | 8 | named_scope :pending, :conditions => { :balanced_at => nil } 9 | named_scope :balanced, :conditions => "balanced_at IS NOT NULL" 10 | 11 | attr_accessible :occurred_on, :ending_balance, :cleared 12 | 13 | validates_presence_of :occurred_on, :ending_balance 14 | 15 | def ending_balance=(amount) 16 | if amount.is_a?(Float) || amount =~ /[.,]/ 17 | amount = (amount.to_s.tr(",", "").to_f * 100).to_i 18 | end 19 | 20 | super(amount) 21 | end 22 | 23 | def balance 24 | ending_balance 25 | end 26 | 27 | def balanced?(reload=false) 28 | unsettled_balance(reload).zero? 29 | end 30 | 31 | def settled_balance(reload=false) 32 | @settled_balance = nil if reload 33 | @settled_balance ||= account_items.to_a.sum(&:amount) 34 | end 35 | 36 | def unsettled_balance(reload=false) 37 | @unsettled_balance = nil if reload 38 | @unsettled_balance ||= starting_balance + settled_balance(reload) - ending_balance 39 | end 40 | 41 | def cleared=(ids) 42 | @ids_to_clear = ids 43 | @already_updated = false 44 | end 45 | 46 | protected 47 | 48 | def initialize_starting_balance 49 | self.starting_balance ||= account.statements.balanced.last.try(:ending_balance) || 0 50 | end 51 | 52 | def associate_account_items_with_self 53 | return if @already_updated 54 | @already_updated = true 55 | 56 | account_items.clear 57 | 58 | ids = connection.select_values(sanitize_sql([<<-SQL.squish, account_id, ids_to_clear])) 59 | SELECT ai.id 60 | FROM account_items ai 61 | WHERE ai.account_id = ? 62 | AND ai.id IN (?) 63 | SQL 64 | 65 | connection.update(sanitize_sql([<<-SQL.squish, id, ids])) 66 | UPDATE account_items 67 | SET statement_id = ? 68 | WHERE id IN (?) 69 | SQL 70 | 71 | account_items.reset 72 | 73 | if @ids_to_clear 74 | if balanced?(true) && !balanced_at 75 | update_attribute :balanced_at, Time.now.utc 76 | elsif !balanced? && balanced_at 77 | update_attribute :balanced_at, nil 78 | end 79 | end 80 | end 81 | 82 | private 83 | 84 | def sanitize_sql(sql) 85 | self.class.send(:sanitize_sql, sql) 86 | end 87 | 88 | def ids_to_clear 89 | @ids_to_clear || [] 90 | end 91 | 92 | end 93 | -------------------------------------------------------------------------------- /test/fixtures/events.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------- 2 | # john 3 | # -------------------------------------------------------------- 4 | 5 | john_checking_starting_balance: 6 | subscription: john 7 | user: john 8 | occurred_on: <%= 60.days.ago.to_date.to_s(:db) %> 9 | actor: Starting balance 10 | check_number: ~ 11 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 12 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 13 | 14 | john_lunch: 15 | subscription: john 16 | user: john 17 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %> 18 | actor: Sandwich Central 19 | check_number: ~ 20 | created_at: <%= 59.days.ago.utc.to_s(:db) %> 21 | updated_at: <%= 59.days.ago.utc.to_s(:db) %> 22 | 23 | john_bill_pay: 24 | subscription: john 25 | user: john 26 | occurred_on: <%= 58.days.ago.to_date.to_s(:db) %> 27 | actor: MasterCard Co. 28 | check_number: 1234 29 | created_at: <%= 58.days.ago.utc.to_s(:db) %> 30 | updated_at: <%= 58.days.ago.utc.to_s(:db) %> 31 | 32 | john_reallocate_from: 33 | subscription: john 34 | user: john 35 | occurred_on: <%= 57.days.ago.to_date.to_s(:db) %> 36 | actor: Bucket reallocation 37 | check_number: ~ 38 | created_at: <%= 57.days.ago.utc.to_s(:db) %> 39 | updated_at: <%= 57.days.ago.utc.to_s(:db) %> 40 | 41 | john_reallocate_to: 42 | subscription: john 43 | user: john 44 | occurred_on: <%= 56.days.ago.to_date.to_s(:db) %> 45 | actor: Bucket reallocation 46 | check_number: ~ 47 | created_at: <%= 56.days.ago.utc.to_s(:db) %> 48 | updated_at: <%= 56.days.ago.utc.to_s(:db) %> 49 | 50 | john_lunch_again: 51 | subscription: john 52 | user: john 53 | occurred_on: <%= 55.days.ago.to_date.to_s(:db) %> 54 | actor: Sandwich Central 55 | check_number: ~ 56 | created_at: <%= 55.days.ago.utc.to_s(:db) %> 57 | updated_at: <%= 55.days.ago.utc.to_s(:db) %> 58 | 59 | john_bare_mastercard: 60 | subscription: john 61 | user: john 62 | occurred_on: <%= 54.days.ago.to_date.to_s(:db) %> 63 | actor: Auto fuel 64 | check_number: ~ 65 | created_at: <%= 54.days.ago.utc.to_s(:db) %> 66 | updated_at: <%= 54.days.ago.utc.to_s(:db) %> 67 | 68 | # -------------------------------------------------------------- 69 | # tim 70 | # -------------------------------------------------------------- 71 | 72 | tim_checking_starting_balance: 73 | subscription: tim 74 | user: tim 75 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %> 76 | actor: Starting balance 77 | check_number: ~ 78 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 79 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 80 | -------------------------------------------------------------------------------- /test/functional/tagged_items_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TaggedItemsControllerTest < ActionController::TestCase 4 | setup :login_default_user 5 | 6 | # == API tests ======================================================================== 7 | 8 | test "create via API for inaccessible event should 404" do 9 | assert_no_difference "TaggedItem.count" do 10 | post :create, :event_id => events(:tim_checking_starting_balance).id, 11 | :tagged_item => { :amount => 100, :tag_id => tags(:john_tip).id }, 12 | :format => "xml" 13 | assert_response :missing 14 | end 15 | end 16 | 17 | test "create via API for inaccessible tag should 404" do 18 | assert_no_difference "TaggedItem.count" do 19 | post :create, :event_id => events(:john_lunch).id, 20 | :tagged_item => { :amount => 100, :tag_id => tags(:tim_milk).id }, 21 | :format => "xml" 22 | assert_response :missing 23 | end 24 | end 25 | 26 | test "create via API should add tagged item and return 201" do 27 | assert_difference "TaggedItem.count" do 28 | post :create, :event_id => events(:john_lunch).id, 29 | :tagged_item => { :amount => 100, :tag_id => tags(:john_fuel).id }, 30 | :format => "xml" 31 | assert_response :success 32 | end 33 | 34 | xml = Hash.from_xml(@response.body) 35 | assert xml.key?("tagged_item") 36 | assert events(:john_lunch, :reload).tagged_items.any? { |i| i.tag == tags(:john_fuel) } 37 | end 38 | 39 | test "create via API should allow tag to be specified by name" do 40 | assert_difference "TaggedItem.count" do 41 | assert_difference "Tag.count" do 42 | post :create, :event_id => events(:john_lunch).id, 43 | :tagged_item => { :amount => 100, :tag_id => "n:misc" }, 44 | :format => "xml" 45 | assert_response :success 46 | end 47 | end 48 | 49 | xml = Hash.from_xml(@response.body) 50 | assert xml.key?("tagged_item") 51 | assert events(:john_lunch, :reload).tagged_items.any? { |i| i.tag.name == "misc" } 52 | end 53 | 54 | test "destroy via API for inaccessible tagged item should 404" do 55 | login! :tim 56 | assert_no_difference "TaggedItem.count" do 57 | delete :destroy, :id => tagged_items(:john_lunch_tip).id, :format => "xml" 58 | assert_response :missing 59 | end 60 | end 61 | 62 | test "destroy via API should remove tagged item from event and return 200" do 63 | assert_difference "TaggedItem.count", -1 do 64 | delete :destroy, :id => tagged_items(:john_lunch_tip).id, :format => "xml" 65 | assert_response :success 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /app/controllers/buckets_controller.rb: -------------------------------------------------------------------------------- 1 | class BucketsController < ApplicationController 2 | acceptable_includes :author 3 | 4 | before_filter :find_account, :only => %w(index new create) 5 | before_filter :find_bucket, :except => %w(index new create) 6 | 7 | def index 8 | respond_to do |format| 9 | format.html 10 | format.xml { render :xml => account.buckets.to_xml(eager_options(:root => "buckets")) } 11 | end 12 | end 13 | 14 | def show 15 | respond_to do |format| 16 | format.html do 17 | @page = (params[:page] || 0).to_i 18 | @more_pages, @items = bucket.line_items.page(@page) 19 | end 20 | 21 | format.xml { render :xml => bucket.to_xml(eager_options) } 22 | end 23 | end 24 | 25 | def new 26 | respond_to { |format| format.xml { render :xml => Bucket.template.to_xml } } 27 | end 28 | 29 | def create 30 | respond_to do |format| 31 | format.xml do 32 | @bucket = account.buckets.create!(params[:bucket], :author => user) 33 | render :status => :created, :xml => @bucket.to_xml, :location => bucket_url(@bucket) 34 | end 35 | end 36 | rescue ActiveRecord::RecordInvalid => error 37 | @bucket = error.record 38 | respond_to do |format| 39 | format.xml { render :status => :unprocessable_entity, :xml => @bucket.errors.to_xml } 40 | end 41 | end 42 | 43 | def update 44 | bucket.update_attributes!(params[:bucket]) 45 | 46 | respond_to do |format| 47 | format.js 48 | format.xml { render :xml => bucket.to_xml } 49 | end 50 | rescue ActiveRecord::RecordInvalid 51 | respond_to do |format| 52 | format.js 53 | format.xml { render :status => :unprocessable_entity, :xml => bucket.errors.to_xml } 54 | end 55 | end 56 | 57 | def destroy 58 | receiver = account.buckets.find(params[:receiver_id]) 59 | receiver.assimilate(bucket) 60 | 61 | respond_to do |format| 62 | format.html { redirect_to(receiver) } 63 | format.xml { head :ok } 64 | end 65 | end 66 | 67 | protected 68 | 69 | attr_reader :account, :bucket 70 | helper_method :account, :bucket 71 | 72 | def find_account 73 | @account = Account.find(params[:account_id]) 74 | @subscription = user.subscriptions.find(@account.subscription_id) 75 | end 76 | 77 | def find_bucket 78 | @bucket = Bucket.find(params[:id]) 79 | @account = @bucket.account 80 | @subscription = user.subscriptions.find(@account.subscription_id) 81 | end 82 | 83 | def current_location 84 | if bucket 85 | "buckets/%d" % bucket.id 86 | else 87 | super 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = BucketWise 2 | 3 | * http://github.com/jamis/bucketwise 4 | 5 | == DESCRIPTION: 6 | 7 | BucketWise is a web application (written in Ruby on Rails) for managing your personal finances. It features an emphasis on budgeting (using a version of the envelope system) and avoiding debt. BucketWise is highly opinionated: it was written for one man's specific needs, and as an experiment, and while it is working very well for that one man, there is no guarantee it will do what you want. (See FEATURES, below). 8 | 9 | == FEATURES: 10 | 11 | * Multiple users 12 | * Track multiple accounts 13 | * Accounts may be partitioned into "buckets", as a means of budgeting and saving money 14 | * Earmark funds for each credit-card purchase to avoid overspending 15 | 16 | Things it intentionally does *not* do: 17 | 18 | * It does NOT automatically pull your transactions from your bank account, and in all likelihood, never will. 19 | * It does NOT intelligently learn from your purchases and try to guess how to categorize them, and in all likelihood, never will. 20 | * It does NOT provide a social forum for you to share thoughts with other users, and DEFINITELY never will. 21 | 22 | == REQUIREMENTS: 23 | 24 | Essentially, all you need is Ruby, Rake, and version 2.3 of the Ruby on Rails framework. To run locally, it is recommended you also have sqlite3 installed, as well as the sqlite3-ruby bindings. It's also easiest if you have Capistrano 2.5.x installed. 25 | 26 | == INSTALL: 27 | 28 | Installation is about as convenient as it is for any other Ruby on Rails application. It goes something like this (and please excuse the gratuitous hand-waving): 29 | 30 | * Get a server on which to install it 31 | * Install all the software it needs 32 | * Configure the database 33 | * Deploy the application 34 | 35 | To get started quickly, you can just run it locally. Setting that up goes something like this: 36 | 37 | * "cap local externals:setup" to load up the dependencies that BucketWise needs. (You'll need to have git installed for this to work.) 38 | * "rake db:schema:load" to prepare the database 39 | * "rake user:create" to create your first user, and note the user-id 40 | * "rake subscription:create USER_ID=" to create your first "subscription" (a container for your financial account data). 41 | * "script/server", and then go to http://localhost:3000/ to log in and get started! 42 | 43 | If you want to use Capistrano to deploy BucketWise to a server of your own, you can simply create a file at ~/.bucketwise/Capfile, which should contain your custom deployment instructions. If that file exists, BucketWise's own config/deploy.rb will load it automatically. 44 | 45 | == LICENSE: 46 | 47 | This software is hereby placed in the public domain. 48 | - Jamis Buck (author), April 2009 49 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # Don't change this file! 2 | # Configure your app in config/environment.rb and config/environments/*.rb 3 | 4 | RAILS_ROOT = "#{File.dirname(__FILE__)}/.." unless defined?(RAILS_ROOT) 5 | 6 | module Rails 7 | class << self 8 | def boot! 9 | unless booted? 10 | preinitialize 11 | pick_boot.run 12 | end 13 | end 14 | 15 | def booted? 16 | defined? Rails::Initializer 17 | end 18 | 19 | def pick_boot 20 | (vendor_rails? ? VendorBoot : GemBoot).new 21 | end 22 | 23 | def vendor_rails? 24 | File.exist?("#{RAILS_ROOT}/vendor/rails") 25 | end 26 | 27 | def preinitialize 28 | load(preinitializer_path) if File.exist?(preinitializer_path) 29 | end 30 | 31 | def preinitializer_path 32 | "#{RAILS_ROOT}/config/preinitializer.rb" 33 | end 34 | end 35 | 36 | class Boot 37 | def run 38 | load_initializer 39 | Rails::Initializer.run(:set_load_path) 40 | end 41 | end 42 | 43 | class VendorBoot < Boot 44 | def load_initializer 45 | require "#{RAILS_ROOT}/vendor/rails/railties/lib/initializer" 46 | Rails::Initializer.run(:install_gem_spec_stubs) 47 | end 48 | end 49 | 50 | class GemBoot < Boot 51 | def load_initializer 52 | self.class.load_rubygems 53 | load_rails_gem 54 | require 'initializer' 55 | end 56 | 57 | def load_rails_gem 58 | if version = self.class.gem_version 59 | gem 'rails', version 60 | else 61 | gem 'rails' 62 | end 63 | rescue Gem::LoadError => load_error 64 | $stderr.puts %(Missing the Rails #{version} gem. Please `gem install -v=#{version} rails`, update your RAILS_GEM_VERSION setting in config/environment.rb for the Rails version you do have installed, or comment out RAILS_GEM_VERSION to use the latest version installed.) 65 | exit 1 66 | end 67 | 68 | class << self 69 | def rubygems_version 70 | Gem::RubyGemsVersion if defined? Gem::RubyGemsVersion 71 | end 72 | 73 | def gem_version 74 | if defined? RAILS_GEM_VERSION 75 | RAILS_GEM_VERSION 76 | elsif ENV.include?('RAILS_GEM_VERSION') 77 | ENV['RAILS_GEM_VERSION'] 78 | else 79 | parse_gem_version(read_environment_rb) 80 | end 81 | end 82 | 83 | def load_rubygems 84 | require 'rubygems' 85 | 86 | unless rubygems_version >= '0.9.4' 87 | $stderr.puts %(Rails requires RubyGems >= 0.9.4 (you have #{rubygems_version}). Please `gem update --system` and try again.) 88 | exit 1 89 | end 90 | 91 | rescue LoadError 92 | $stderr.puts %(Rails requires RubyGems >= 0.9.4. Please install RubyGems and try again: http://rubygems.rubyforge.org) 93 | exit 1 94 | end 95 | 96 | def parse_gem_version(text) 97 | $1 if text =~ /^[^#]*RAILS_GEM_VERSION\s*=\s*["']([!~<>=]*\s*[\d.]+)["']/ 98 | end 99 | 100 | private 101 | def read_environment_rb 102 | File.read("#{RAILS_ROOT}/config/environment.rb") 103 | end 104 | end 105 | end 106 | end 107 | 108 | # All that for this: 109 | Rails.boot! 110 | -------------------------------------------------------------------------------- /test/unit/statement_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class StatementTest < ActiveSupport::TestCase 4 | test "creation with ending balance as dollars should be translated to cents" do 5 | statement = accounts(:john_checking).statements.create(:occurred_on => Date.today, 6 | :ending_balance => "1,234.56") 7 | assert_equal 1_234_56, statement.ending_balance 8 | end 9 | 10 | test "creation with cleared ids should set statement id for given account items" do 11 | items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id } 12 | 13 | statement = accounts(:john_checking).statements.create( 14 | :occurred_on => Date.today, :ending_balance => 1_234_56, :cleared => items) 15 | 16 | assert_equal items, statement.account_items.map(&:id) 17 | end 18 | 19 | test "creation with cleared ids should filter out items for different accounts" do 20 | items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id } 21 | bad_items = items + [account_items(:john_lunch_mastercard).id] 22 | 23 | statement = accounts(:john_checking).statements.create( 24 | :occurred_on => Date.today, :ending_balance => 1_234_56, :cleared => bad_items) 25 | 26 | assert_equal items, statement.account_items.map(&:id) 27 | end 28 | 29 | test "balanced_at should not be set when no items have been given" do 30 | statement = accounts(:john_checking).statements.create(:occurred_on => Date.today, 31 | :ending_balance => 1_234_56) 32 | assert_nil statement.balanced_at 33 | end 34 | 35 | test "balanced_at should be set automatically when items all balance" do 36 | statements(:john).destroy # get this one out of the way 37 | 38 | items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id } 39 | 40 | statement = accounts(:john_checking).statements.create( 41 | :occurred_on => Date.today, :ending_balance => 992_25, :cleared => items) 42 | 43 | assert_not_nil statement.balanced_at 44 | assert (Time.now - statement.balanced_at) < 1 45 | end 46 | 47 | test "balanced_at should be cleared automatically when items do not balance" do 48 | items = [:john_checking_starting_balance, :john_bill_pay_checking].map { |i| account_items(i).id } 49 | 50 | statement = accounts(:john_checking).statements.create(:occurred_on => Date.today, 51 | :ending_balance => 1_234_56) 52 | statement.balanced_at = Time.now.utc 53 | statement.save 54 | 55 | assert_not_nil statement.reload.balanced_at 56 | 57 | statement.update_attributes :cleared => items 58 | assert_nil statement.reload.balanced_at 59 | end 60 | 61 | test "deleting statement should nullify association with account items" do 62 | assert statements(:john).account_items.any? 63 | assert_equal statements(:john), account_items(:john_checking_starting_balance).statement 64 | 65 | assert_difference "Statement.count", -1 do 66 | assert_no_difference "AccountItem.count" do 67 | statements(:john).destroy 68 | end 69 | end 70 | 71 | assert_nil account_items(:john_checking_starting_balance, :reload).statement 72 | end 73 | 74 | test "balanced should be true when unsettled balance is zero" do 75 | assert statements(:john).balanced? 76 | statements(:john).update_attributes :ending_balance => 1234_56, 77 | :cleared => statements(:john).account_items.map(&:id) 78 | assert !statements(:john).balanced?(true) 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/recipes/cached_externals.rb: -------------------------------------------------------------------------------- 1 | # --------------------------------------------------------------------------- 2 | # This is a recipe definition file for Capistrano. The tasks are documented 3 | # below. 4 | # --------------------------------------------------------------------------- 5 | # This file is distributed under the terms of the MIT license by 37signals, 6 | # LLC, and is copyright (c) 2008 by the same. See the LICENSE file distributed 7 | # with this file for the complete text of the license. 8 | # --------------------------------------------------------------------------- 9 | 10 | # The :external_modules variable is used internally to load and contain the 11 | # contents of the config/externals.yml file. Although you _could_ set the 12 | # variable yourself (to bypas the need for a config/externals.yml file, for 13 | # instance), you'll rarely (if ever) want to. 14 | set(:external_modules) do 15 | require 'yaml' 16 | 17 | modules = YAML.load_file("config/externals.yml") rescue {} 18 | modules.each do |path, options| 19 | strings = options.select { |k, v| String === k } 20 | raise ArgumentError, "the externals.yml file must use symbols for the option keys (found #{strings.inspect} under #{path})" if strings.any? 21 | end 22 | end 23 | 24 | desc "Indicate that externals should be applied locally. See externals:setup." 25 | task :local do 26 | set :stage, :local 27 | end 28 | 29 | namespace :externals do 30 | desc <<-DESC 31 | Set up all defined external modules. This will check to see if any of the 32 | modules need to be checked out (be they new or just updated), and will then 33 | create symlinks to them. If running in 'local' mode (see the :local task) 34 | then these will be created in a "../shared/externals" directory relative 35 | to the project root. Otherwise, these will be created on the remote 36 | machines under [shared_path]/externals. 37 | DESC 38 | task :setup, :except => { :no_release => true } do 39 | require 'capistrano/recipes/deploy/scm' 40 | 41 | external_modules.each do |path, options| 42 | puts "configuring #{path}" 43 | scm = Capistrano::Deploy::SCM.new(options[:type], options) 44 | revision = scm.query_revision(options[:revision]) { |cmd| `#{cmd}` } 45 | 46 | if exists?(:stage) && stage == :local 47 | FileUtils.rm_rf(path) 48 | shared = File.expand_path(File.join("../shared/externals", path)) 49 | FileUtils.mkdir_p(shared) 50 | destination = File.join(shared, revision) 51 | if !File.exists?(destination) 52 | unless system(scm.checkout(revision, destination)) 53 | FileUtils.rm_rf(destination) if File.exists?(destination) 54 | raise "Error checking out #{revision} to #{destination}" 55 | end 56 | end 57 | FileUtils.ln_s(destination, path) 58 | else 59 | shared = File.join(shared_path, "externals", path) 60 | destination = File.join(shared, revision) 61 | run "rm -rf #{latest_release}/#{path} && mkdir -p #{shared} && if [ ! -d #{destination} ]; then (#{scm.checkout(revision, destination)}) || rm -rf #{destination}; fi && ln -nsf #{destination} #{latest_release}/#{path}" 62 | end 63 | end 64 | end 65 | end 66 | 67 | # Need to do this before finalize_update, instead of after update_code, 68 | # because finalize_update tries to do a touch of all assets, and some 69 | # assets might be symlinks to files in plugins that have been externalized. 70 | # Updating those externals after finalize_update means that the plugins 71 | # haven't been set up yet when the touch occurs, causing the touch to 72 | # fail and leaving some assets temporally out of sync, potentially, with 73 | # the other servers. 74 | before "deploy:finalize_update", "externals:setup" 75 | -------------------------------------------------------------------------------- /test/fixtures/line_items.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------- 2 | # john 3 | # -------------------------------------------------------------- 4 | 5 | john_checking_starting_balance: 6 | event: john_checking_starting_balance 7 | account: john_checking 8 | bucket: john_checking_general 9 | amount: 100000 10 | role: deposit 11 | occurred_on: <%= 60.days.ago.to_date.to_s(:db) %> 12 | 13 | john_lunch_mastercard: 14 | event: john_lunch 15 | account: john_mastercard 16 | bucket: john_mastercard_general 17 | amount: -775 18 | role: payment_source 19 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %> 20 | 21 | john_lunch_checking_dining: 22 | event: john_lunch 23 | account: john_checking 24 | bucket: john_checking_dining 25 | amount: -775 26 | role: credit_options 27 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %> 28 | 29 | john_lunch_checking_aside: 30 | event: john_lunch 31 | account: john_checking 32 | bucket: john_checking_aside 33 | amount: 775 34 | role: aside 35 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %> 36 | 37 | john_bill_pay_checking_aside: 38 | event: john_bill_pay 39 | account: john_checking 40 | bucket: john_checking_aside 41 | amount: -775 42 | role: transfer_from 43 | occurred_on: <%= 58.days.ago.utc.to_s(:db) %> 44 | 45 | john_bill_pay_mastercard_general: 46 | event: john_bill_pay 47 | account: john_mastercard 48 | bucket: john_mastercard_general 49 | amount: 775 50 | role: transfer_to 51 | occurred_on: <%= 58.days.ago.utc.to_s(:db) %> 52 | 53 | john_reallocate_from_general: 54 | event: john_reallocate_from 55 | account: john_checking 56 | bucket: john_general 57 | amount: -1000 58 | role: primary 59 | occurred_on: <%= 57.days.ago.utc.to_s(:db) %> 60 | 61 | john_reallocate_from_dining: 62 | event: john_reallocate_from 63 | account: john_checking 64 | bucket: john_checking_dining 65 | amount: 1000 66 | role: reallocate_from 67 | occurred_on: <%= 57.days.ago.utc.to_s(:db) %> 68 | 69 | john_reallocate_to_general: 70 | event: john_reallocate_to 71 | account: john_checking 72 | bucket: john_general 73 | amount: -1000 74 | role: reallocate_to 75 | occurred_on: <%= 56.days.ago.utc.to_s(:db) %> 76 | 77 | john_reallocate_to_dining: 78 | event: john_reallocate_to 79 | account: john_checking 80 | bucket: john_checking_dining 81 | amount: 1000 82 | role: primary 83 | occurred_on: <%= 56.days.ago.utc.to_s(:db) %> 84 | 85 | john_lunch_again_mastercard: 86 | event: john_lunch_again 87 | account: john_mastercard 88 | bucket: john_mastercard_general 89 | amount: -1025 90 | role: payment_source 91 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %> 92 | 93 | john_lunch_again_checking_dining: 94 | event: john_lunch_again 95 | account: john_checking 96 | bucket: john_checking_dining 97 | amount: -1025 98 | role: credit_options 99 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %> 100 | 101 | john_lunch_again_checking_aside: 102 | event: john_lunch_again 103 | account: john_checking 104 | bucket: john_checking_aside 105 | amount: 1025 106 | role: aside 107 | occurred_on: <%= 59.days.ago.utc.to_s(:db) %> 108 | 109 | john_bare_mastercard_general: 110 | event: john_bare_mastercard 111 | account: john_mastercard 112 | bucket: john_mastercard_general 113 | amount: -1500 114 | role: payment_source 115 | occurred_on: <%= 54.days.ago.utc.to_s(:db) %> 116 | 117 | # -------------------------------------------------------------- 118 | # tim 119 | # -------------------------------------------------------------- 120 | 121 | tim_checking_starting_balance: 122 | event: tim_checking_starting_balance 123 | account: tim_checking 124 | bucket: tim_checking_general 125 | amount: 125000 126 | role: deposit 127 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %> 128 | 129 | -------------------------------------------------------------------------------- /app/models/account.rb: -------------------------------------------------------------------------------- 1 | class Account < ActiveRecord::Base 2 | DEFAULT_BUCKET_NAME = "General" 3 | 4 | belongs_to :subscription 5 | belongs_to :author, :class_name => "User", :foreign_key => "user_id" 6 | 7 | attr_accessor :starting_balance 8 | attr_accessible :name, :role, :starting_balance 9 | 10 | validates_presence_of :name 11 | validates_uniqueness_of :name, :scope => :subscription_id, :case_sensitive => false 12 | 13 | has_many :buckets do 14 | def for_role(role, user) 15 | role = role.downcase 16 | find_by_role(role) || create({:name => role.capitalize, :role => role}, :author => user) 17 | end 18 | 19 | def sorted 20 | sort_by(&:name) 21 | end 22 | 23 | def default 24 | detect { |bucket| bucket.role == "default" } 25 | end 26 | 27 | def recent(n=5) 28 | find(:all, :limit => n, :order => "updated_at DESC").sort_by(&:name) 29 | end 30 | 31 | def with_defaults 32 | buckets = to_a 33 | buckets << Bucket.default unless buckets.any? { |bucket| bucket.role == "default" } 34 | buckets << Bucket.aside unless buckets.any? { |bucket| bucket.role == "aside" } 35 | return buckets 36 | end 37 | end 38 | 39 | has_many :line_items 40 | has_many :statements 41 | 42 | has_many :account_items, :extend => CategorizedItems 43 | 44 | after_create :create_default_buckets, :set_starting_balance 45 | 46 | def self.template 47 | new :name => "Account name (e.g. Checking)", :role => "checking | credit-card | nil", 48 | :starting_balance => { :amount => 0, :occurred_on => Date.today } 49 | end 50 | 51 | def credit_card? 52 | role == 'credit-card' 53 | end 54 | 55 | def checking? 56 | role == 'checking' 57 | end 58 | 59 | def available_balance 60 | @available_balance ||= balance - unavailable_balance 61 | end 62 | 63 | def unavailable_balance 64 | @unavailable_balance ||= begin 65 | aside = buckets.detect { |bucket| bucket.role == 'aside' } 66 | aside && aside.balance > 0 ? aside.balance : 0 67 | end 68 | end 69 | 70 | def destroy 71 | transaction do 72 | LineItem.delete_all :account_id => id 73 | AccountItem.delete_all :account_id => id 74 | Bucket.delete_all :account_id => id 75 | 76 | tagged_items = TaggedItem.find_by_sql(<<-SQL.squish) 77 | SELECT t.* FROM tagged_items t LEFT JOIN events e ON t.event_id = e.id 78 | WHERE e.subscription_id = #{subscription_id} 79 | AND NOT EXISTS ( 80 | SELECT * FROM account_items a WHERE a.event_id = e.id) 81 | SQL 82 | 83 | tagged_items.each { |item| item.destroy } 84 | 85 | connection.delete(<<-SQL.squish) 86 | DELETE FROM events 87 | WHERE subscription_id = #{subscription_id} 88 | AND NOT EXISTS ( 89 | SELECT * FROM account_items a WHERE a.event_id = events.id) 90 | SQL 91 | 92 | Account.delete(id) 93 | end 94 | end 95 | 96 | def to_xml(options={}) 97 | if new_record? 98 | options[:only] = %w(name role) 99 | options[:procs] = Proc.new do |opts| 100 | starting_balance.to_xml(opts.merge(:root => "starting-balance")) 101 | end 102 | end 103 | 104 | super(options) 105 | end 106 | 107 | protected 108 | 109 | def create_default_buckets 110 | buckets.create({:name => DEFAULT_BUCKET_NAME, :role => "default"}, :author => author) 111 | end 112 | 113 | def set_starting_balance 114 | if starting_balance && !starting_balance[:amount].to_i.zero? 115 | subscription.events.create({:occurred_on => starting_balance[:occurred_on], 116 | :actor => "Starting balance", 117 | :line_items => [{:account_id => id, :bucket_id => buckets.default.id, 118 | :amount => starting_balance[:amount], :role => "deposit"}] 119 | }, :user => author) 120 | reload # make sure the balance is set correctly 121 | end 122 | end 123 | end 124 | -------------------------------------------------------------------------------- /db/migrate/20080513032848_initial_schema.rb: -------------------------------------------------------------------------------- 1 | class InitialSchema < ActiveRecord::Migration 2 | def self.up 3 | create_table :subscriptions do |t| 4 | t.integer :owner_id, :null => false 5 | end 6 | 7 | add_index :subscriptions, :owner_id 8 | 9 | create_table :users do |t| 10 | t.string :name, :null => false 11 | t.string :email, :null => false 12 | t.string :user_name 13 | t.string :password_hash 14 | t.string :salt 15 | t.timestamps 16 | end 17 | 18 | add_index :users, :user_name, :unique => true 19 | 20 | create_table :user_subscriptions do |t| 21 | t.integer :subscription_id, :null => false 22 | t.integer :user_id, :null => false 23 | t.datetime :created_at, :null => false 24 | end 25 | 26 | add_index :user_subscriptions, %w(subscription_id user_id), :unique => true 27 | add_index :user_subscriptions, :user_id 28 | 29 | create_table :accounts do |t| 30 | t.integer :subscription_id, :null => false 31 | t.integer :user_id, :null => false 32 | t.string :name, :null => false 33 | t.string :role 34 | t.timestamps 35 | end 36 | 37 | add_index :accounts, %w(subscription_id name), :unique => true 38 | 39 | create_table :buckets do |t| 40 | t.integer :account_id, :null => false 41 | t.integer :user_id, :null => false 42 | t.string :name, :null => false 43 | t.string :role 44 | t.timestamps 45 | end 46 | 47 | add_index :buckets, %w(account_id name), :unique => true 48 | add_index :buckets, %w(account_id updated_at) 49 | 50 | create_table :events do |t| 51 | t.integer :subscription_id, :null => false 52 | t.integer :user_id, :null => false 53 | t.date :occurred_on, :null => false 54 | t.string :actor, :null => false 55 | t.integer :check_number 56 | t.timestamps 57 | end 58 | 59 | add_index :events, %w(subscription_id occurred_on) 60 | add_index :events, %w(subscription_id actor) 61 | add_index :events, %w(subscription_id check_number) 62 | add_index :events, %w(subscription_id created_at) 63 | 64 | create_table :line_items do |t| 65 | t.integer :event_id, :null => false 66 | t.integer :account_id, :null => false 67 | t.integer :bucket_id, :null => false 68 | t.integer :amount, :null => false # cents 69 | t.string :role, :limit => 20 70 | t.date :occurred_on, :null => false 71 | end 72 | 73 | add_index :line_items, :event_id 74 | add_index :line_items, :account_id 75 | add_index :line_items, %w(bucket_id occurred_on) 76 | 77 | create_table :account_items do |t| 78 | t.integer :event_id, :null => false 79 | t.integer :account_id, :null => false 80 | t.integer :amount, :null => false # cents 81 | t.date :occurred_on, :null => false 82 | end 83 | 84 | add_index :account_items, :event_id 85 | add_index :account_items, %w(account_id occurred_on) 86 | 87 | create_table :tags do |t| 88 | t.integer :subscription_id, :null => false 89 | t.string :name, :null => false 90 | t.integer :balance, :null => false, :default => 0 91 | t.timestamps 92 | end 93 | 94 | add_index :tags, %w(subscription_id name), :unique => true 95 | add_index :tags, %w(subscription_id balance) 96 | 97 | create_table :tagged_items do |t| 98 | t.integer :event_id, :null => false 99 | t.integer :tag_id, :null => false 100 | t.date :occurred_on, :null => false 101 | t.integer :amount, :null => false 102 | end 103 | 104 | add_index :tagged_items, :event_id 105 | add_index :tagged_items, %w(tag_id occurred_on) 106 | end 107 | 108 | def self.down 109 | drop_table :subscriptions 110 | drop_table :users 111 | drop_table :subscribed_users 112 | drop_table :accounts 113 | drop_table :buckets 114 | drop_table :events 115 | drop_table :line_items 116 | drop_table :account_items 117 | drop_table :tags 118 | drop_table :tagged_items 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /app/controllers/events_controller.rb: -------------------------------------------------------------------------------- 1 | class EventsController < ApplicationController 2 | acceptable_includes :line_items, :user, :tagged_items 3 | 4 | before_filter :find_container, :find_events, :only => :index 5 | before_filter :find_subscription, :only => %w(new create) 6 | before_filter :find_event, :except => %w(index new create) 7 | 8 | def index 9 | respond_to do |format| 10 | format.xml do 11 | render :xml => events.to_xml(eager_options(:root => "events")) 12 | end 13 | end 14 | end 15 | 16 | def show 17 | respond_to do |format| 18 | format.js 19 | format.xml { render :xml => event.to_xml(eager_options) } 20 | end 21 | end 22 | 23 | def edit 24 | end 25 | 26 | def new 27 | @event = subscription.events.prepare(params) 28 | 29 | respond_to do |format| 30 | format.html 31 | format.xml { render :xml => event.to_xml(:include => [:line_items, :tagged_items]) } 32 | end 33 | end 34 | 35 | def create 36 | @event = subscription.events.create!(params[:event], :user => user) 37 | respond_to do |format| 38 | format.js 39 | format.xml do 40 | render :status => :created, :location => event_url(@event), 41 | :xml => @event.to_xml(:include => [:line_items, :tagged_items]) 42 | end 43 | end 44 | rescue ActiveRecord::RecordInvalid => error 45 | @event = error.record 46 | respond_to do |format| 47 | format.js 48 | format.xml { render :status => :unprocessable_entity, :xml => @event.errors } 49 | end 50 | end 51 | 52 | def update 53 | event.update_attributes!(params[:event]) 54 | respond_to do |format| 55 | format.js 56 | format.xml { render :xml => event.to_xml(:include => [:line_items, :tagged_items]) } 57 | end 58 | rescue ActiveRecord::RecordInvalid => error 59 | respond_to do |format| 60 | format.js 61 | format.xml { render :status => :unprocessable_entity, :xml => event.errors } 62 | end 63 | end 64 | 65 | def destroy 66 | event.destroy 67 | respond_to do |format| 68 | format.js 69 | format.xml { head :ok } 70 | end 71 | end 72 | 73 | protected 74 | 75 | attr_reader :event, :container, :account, :bucket, :tag, :events 76 | helper_method :event 77 | 78 | def find_event 79 | @event = Event.find(params[:id]) 80 | @subscription = user.subscriptions.find(@event.subscription_id) 81 | end 82 | 83 | def find_container 84 | if params[:subscription_id] 85 | @container = find_subscription 86 | elsif params[:account_id] 87 | @container = @account = Account.find(params[:account_id]) 88 | @subscription = user.subscriptions.find(@account.subscription_id) 89 | elsif params[:bucket_id] 90 | @container = @bucket = Bucket.find(params[:bucket_id]) 91 | @subscription = user.subscriptions.find(@bucket.account.subscription_id) 92 | elsif params[:tag_id] 93 | @container = @tag = Tag.find(params[:tag_id]) 94 | @subscription = user.subscriptions.find(@tag.subscription_id) 95 | else 96 | raise ArgumentError, "no container specified for event listing" 97 | end 98 | end 99 | 100 | def find_events 101 | method = :page 102 | 103 | case container 104 | when Subscription then 105 | association = :events 106 | method = :recent 107 | when Account 108 | association = :account_items 109 | when Bucket 110 | association = :line_items 111 | when Tag 112 | association = :tagged_items 113 | else 114 | raise ArgumentError, "unsupported container type #{container.class}" 115 | end 116 | 117 | more_pages, list = container.send(association).send(method, params[:page], :size => params[:size]) 118 | unless list.first.is_a?(Event) 119 | list = list.map do |item| 120 | event = item.event 121 | event.amount = item.amount 122 | event 123 | end 124 | end 125 | 126 | @events = list 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /test/functional/statements_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class StatementsControllerTest < ActionController::TestCase 4 | setup :login_default_user 5 | 6 | test "index for inaccessible account should 404" do 7 | get :index, :account_id => accounts(:tim_checking).id 8 | assert_response :missing 9 | end 10 | 11 | test "index should list only balanced statements" do 12 | get :index, :account_id => accounts(:john_checking).id 13 | assert_response :success 14 | assert_template "statements/index" 15 | assert_equal [statements(:john)], assigns(:statements) 16 | end 17 | 18 | test "new for inaccessible account should 404" do 19 | get :new, :account_id => accounts(:tim_checking).id 20 | assert_response :missing 21 | end 22 | 23 | test "new should build template record and render" do 24 | get :new, :account_id => accounts(:john_checking).id 25 | assert_response :success 26 | assert_template "statements/new" 27 | assert assigns(:statement).new_record? 28 | end 29 | 30 | test "create for inaccessible account should 404" do 31 | assert_no_difference "Statement.count" do 32 | post :create, :account_id => accounts(:tim_checking).id, 33 | :statement => { :occurred_on => Date.today, :ending_balance => 1234_56 } 34 | assert_response :missing 35 | end 36 | end 37 | 38 | test "create should create new record and redirect to edit" do 39 | assert_difference "accounts(:john_checking, :reload).statements.size" do 40 | post :create, :account_id => accounts(:john_checking).id, 41 | :statement => { :occurred_on => Date.today, :ending_balance => 1234_56 } 42 | assert_redirected_to edit_statement_url(assigns(:statement)) 43 | end 44 | end 45 | 46 | test "show for inaccessible statement should 404" do 47 | get :show, :id => statements(:tim).id 48 | assert_response :missing 49 | end 50 | 51 | test "show should load statement record and render" do 52 | get :show, :id => statements(:john).id 53 | assert_response :success 54 | assert_template "statements/show" 55 | assert_equal statements(:john), assigns(:statement) 56 | end 57 | 58 | test "edit for inaccessible statement should 404" do 59 | get :edit, :id => statements(:tim).id 60 | assert_response :missing 61 | end 62 | 63 | test "edit should load statement record and render" do 64 | get :edit, :id => statements(:john).id 65 | assert_response :success 66 | assert_template "statements/edit" 67 | assert_equal statements(:john), assigns(:statement) 68 | end 69 | 70 | test "update for inaccessible statement should 404" do 71 | put :update, :id => statements(:tim).id, 72 | :statement => { :occurred_on => statements(:tim).occurred_on, 73 | :ending_balance => statements(:tim).ending_balance, 74 | :cleared => [account_items(:tim_checking_starting_balance).id] } 75 | assert_response :missing 76 | assert statements(:tim, :reload).account_items.empty? 77 | end 78 | 79 | test "update should load statement and update statement and redirect" do 80 | assert statements(:john_pending).account_items.empty? 81 | put :update, :id => statements(:john_pending).id, 82 | :statement => { :occurred_on => statements(:john_pending).occurred_on, 83 | :ending_balance => statements(:john_pending).ending_balance, 84 | :cleared => [account_items(:john_lunch_again_checking).id] } 85 | assert_redirected_to account_url(statements(:john_pending).account) 86 | assert_equal [account_items(:john_lunch_again_checking)], 87 | statements(:john_pending, :reload).account_items 88 | end 89 | 90 | test "destroy for inaccessible statement should 404" do 91 | assert_no_difference "Statement.count" do 92 | delete :destroy, :id => statements(:tim).id 93 | assert_response :missing 94 | end 95 | end 96 | 97 | test "destroy should load and destroy statement and redirect" do 98 | delete :destroy, :id => statements(:john).id 99 | assert_redirected_to account_url(statements(:john).account) 100 | assert !Statement.exists?(statements(:john).id) 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/unit/account_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class AccountTest < ActiveSupport::TestCase 4 | test "create should also create default bucket" do 5 | assert_difference "Bucket.count" do 6 | a = new_account 7 | assert_equal %w(default), a.buckets.map(&:role) 8 | assert_equal a.author, a.buckets.first.author 9 | end 10 | end 11 | 12 | test "create without starting balance should initialize with zero items" do 13 | assert_no_difference "Event.count" do 14 | a = new_account 15 | assert a.line_items.count.zero? 16 | assert a.account_items.count.zero? 17 | assert a.balance.zero? 18 | end 19 | end 20 | 21 | test "create with starting balance should initialize balance" do 22 | assert_difference "subscriptions(:john).events.count" do 23 | a = new_account :starting_balance => { 24 | :occurred_on => 1.week.ago.utc, :amount => "12345" } 25 | assert_equal [a.buckets.default], a.line_items.map(&:bucket) 26 | assert_equal 12345, a.balance 27 | end 28 | end 29 | 30 | test "create duplicate account should fail" do 31 | assert_no_difference "Account.count" do 32 | account = new_account :name => accounts(:john_checking).name.upcase 33 | assert !account.valid? 34 | assert account.new_record? 35 | end 36 | end 37 | 38 | test "duplicates should be scoped to subscription" do 39 | assert_difference "Account.count" do 40 | account = new_account :subscription => subscriptions(:tim), :name => accounts(:john_savings).name 41 | assert account.valid? 42 | end 43 | end 44 | 45 | test "create with blank name should fail" do 46 | assert_no_difference "Account.count" do 47 | account = new_account :name => "" 48 | assert !account.valid? 49 | assert account.new_record? 50 | end 51 | end 52 | 53 | test "available balance should be the same as balance when there is no aside" do 54 | assert !accounts(:john_savings).buckets.detect { |bucket| bucket.role == 'aside' } 55 | assert_equal accounts(:john_savings).available_balance, accounts(:john_savings).balance 56 | end 57 | 58 | test "unavailable balance should exclude aside balance" do 59 | checking = accounts(:john_checking) 60 | aside = buckets(:john_checking_aside) 61 | assert !aside.balance.zero? 62 | assert_equal checking.available_balance, checking.balance - aside.balance 63 | end 64 | 65 | test "destroy should remove all line items referencing this account" do 66 | accounts(:john_mastercard).destroy 67 | assert !Account.exists?(accounts(:john_mastercard).id) 68 | assert LineItem.find(:all, :conditions => { :account_id => accounts(:john_mastercard).id }).empty? 69 | end 70 | 71 | test "destroy should remove all account items referencing this account" do 72 | accounts(:john_mastercard).destroy 73 | assert !Account.exists?(accounts(:john_mastercard).id) 74 | assert AccountItem.find(:all, :conditions => { :account_id => accounts(:john_mastercard).id }).empty? 75 | end 76 | 77 | test "destroy should remove all buckets referencing this account" do 78 | accounts(:john_mastercard).destroy 79 | assert !Account.exists?(accounts(:john_mastercard).id) 80 | assert Bucket.find(:all, :conditions => { :account_id => accounts(:john_mastercard).id }).empty? 81 | end 82 | 83 | test "destroy should remove all events referencing only this account" do 84 | assert Event.exists?(events(:john_bare_mastercard).id) 85 | assert Event.exists?(events(:john_lunch).id) 86 | assert Event.exists?(events(:john_bill_pay).id) 87 | accounts(:john_mastercard).destroy 88 | assert !Event.exists?(events(:john_bare_mastercard).id) 89 | assert Event.exists?(events(:john_lunch).id) 90 | assert Event.exists?(events(:john_bill_pay).id) 91 | end 92 | 93 | test "destroy should remove all tagged items for events referencing only this account" do 94 | assert_equal 1500, tags(:john_fuel).balance 95 | accounts(:john_mastercard).destroy 96 | assert_equal 0, tags(:john_fuel, :reload).balance 97 | end 98 | 99 | private 100 | 101 | def new_account(options={}) 102 | subscription = options.delete(:subscription) || subscriptions(:john) 103 | 104 | options = {:name => "Visa", 105 | :role => "credit-card"}.merge(options) 106 | 107 | subscription.accounts.create(options, :author => users(:john)) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/README.rdoc: -------------------------------------------------------------------------------- 1 | = Cached Externals 2 | 3 | This Capistrano extension provides yet another way to manage your application's external dependencies. 4 | 5 | External dependencies are a necessary evil. If you're doing Rails development, you'll automatically be dependent on the Rails framework itself, at the very least, and you probably have a handful or two of plugins that you're using as well. Traditionally, the solution to these has been to either install those dependencies globally (which breaks down when you have multiple apps that need different versions of Rails or the plugins), or to bundle them into your application directly via freeze:gems or the like (which tends to bloat your repository). 6 | 7 | Now, bloating a repository is, in itself, not a big deal. However, when it comes time to deploy your application, more data in the repository means more data to check out, resulting in longer deploy times. Some deployment strategies (like remote_cache) can help, but you're _still_ going to be copying all of that data every time you deploy your application. 8 | 9 | One solution is to use a deployment strategy like FastRemoteCache (http://github.com/37signals/fast_remote_cache), which employs hard-links instead of copying files. But your deploys will go _even faster_ if you didn't have to worry about moving or linking all the files in your external dependencies. 10 | 11 | That's where this Cached Externals plugin comes in. It capitalizes on one key concept: your external dependencies are unlikely to change frequently. Because they don't change frequently, it makes more sense to just check them out once, and simply refer to that checkout via symlinks. 12 | 13 | This means your deploys only have to add a single symbolic link for each external dependency, which can mean orders of magnitude less work for a deploy to do. 14 | 15 | == Dependencies 16 | 17 | * Capistrano 2 or later (http://www.capify.org) 18 | 19 | == Assumptions 20 | 21 | The Cached Externals plugin assumes that your external dependencies are under version control somewhere, and the repositories are accessible both locally and from your deployment targets. 22 | 23 | == Usage 24 | 25 | When using this with Rails applications, all you need to do is install this as a plugin in your application. Make sure your Capfile has a line like the following: 26 | 27 | Dir['vendor/plugins/*/recipes/*.rb'].each { |plugin| load(plugin) } 28 | 29 | It should be just before the line that loads the 'config/deploy' file. If that line is not there, add it (or rerun "capify ."). 30 | 31 | If you are not using this with a Rails application, you'll need to explicitly load recipes/cached_externals.rb in your Capfile. 32 | 33 | Next, tell Capistrano about your external dependencies. This is done via a YAML file: config/externals.yml. It describes a hash of path names that describe the external repository. For example: 34 | 35 | --- 36 | vendor/rails: 37 | :type: git 38 | :repository: git://github.com/rails/rails.git 39 | :revision: f2d8d13c6495f2a9b3bbf3b50d869c0e5b25c207 40 | vendor/plugins/exception_notification: 41 | :type: git 42 | :repository: git://github.com/rails/exception_notification.git 43 | :revision: ed0b914ff493f9137abc4f68ee08e3c3cd7a3211 44 | 45 | Specify as many as you like. Although it is best to specify an exact revision, you can also give any revision identifier that capistrano understands (git branches, HEAD, etc.). If you do, though, Capistrano will have to resolve those pseudo-revision identifiers every time, which can slow things down a little. 46 | 47 | Once you've got your externals.yml in place, you'll need to clear away the cruft. For example, if you were going to put vendor/rails as a cached external dependency, you'd need to first remove vendor/rails from your working copy. After clearing away all of the in-repository copies of your external dependencies, you just tell capistrano to load up the cached copies: 48 | 49 | cap local externals:setup 50 | 51 | That will cause Capistrano to read your externals.yml, checkout your dependencies, and create symlinks to them. When run locally like that, the dependencies will be checked out to ../shared/externals (e.g., up a directory from your project root). 52 | 53 | Any time you update your config/externals.yml, you'll need to rerun that command. If an external hasn't changed revision, Capistrano will notice that and will not check it out again--only those that have changed will be updated. 54 | 55 | When you deploy your application, these externals will be checked out into #{shared_path}/externals, and again, nothing will be checked out if the dependency's revision matches what has already been cached. 56 | 57 | == Tips 58 | 59 | For the fastest possible deploys, always give an exact revision identifier. This way, Capistrano may not have to query the dependency's repository at all, if nothing has changed. 60 | 61 | Also, if you're using git, it can be a pain to change branches with this plugin, because different branches may have different dependencies, or (even worse) the same dependency but at different revisions. This plugin provides a Rake task, "git:hooks:install", that installs a couple of git hook scripts: post-checkout and post-merge. (If you already have scripts defined for those, back them up before running this task, because they'll get clobbered!) These hooks will then make it so that any time you switch branches, or do a "git pull" or "git merge", the "cap local externals:setup" task will also get run, keeping your external dependencies in sync. 62 | 63 | == License 64 | 65 | This code is released under the MIT license, and is copyright (c) 2008 by 37signals, LLC. Please see the accompanying LICENSE file for the full text of the license. 66 | -------------------------------------------------------------------------------- /test/functional/buckets_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BucketsControllerTest < ActionController::TestCase 4 | setup :login_default_user 5 | 6 | test "index should 404 when when user without permissions requests page" do 7 | get :index, :account_id => accounts(:tim_checking).id 8 | assert_response :missing 9 | end 10 | 11 | test "show should 404 when when user without permissions requests page" do 12 | get :show, :id => buckets(:tim_checking_general).id 13 | assert_response :missing 14 | end 15 | 16 | test "update should 404 when user without permissions requests page" do 17 | xhr :put, :update, :id => buckets(:tim_checking_general).id, :bucket => { :name => "Hi!" } 18 | assert_response :missing 19 | assert_equal "General", buckets(:tim_checking_general, :reload).name 20 | end 21 | 22 | test "index should load account and subscription and render page" do 23 | get :index, :account_id => accounts(:john_checking).id 24 | assert_response :success 25 | assert_template "buckets/index" 26 | assert_equal subscriptions(:john), assigns(:subscription) 27 | assert_equal accounts(:john_checking), assigns(:account) 28 | end 29 | 30 | test "show should load bucket, account, and subscription and render page" do 31 | get :show, :id => buckets(:john_checking_dining).id 32 | assert_response :success 33 | assert_template "buckets/show" 34 | assert_equal subscriptions(:john), assigns(:subscription) 35 | assert_equal accounts(:john_checking), assigns(:account) 36 | assert_equal buckets(:john_checking_dining), assigns(:bucket) 37 | end 38 | 39 | test "update should change bucket name and render javascript" do 40 | xhr :put, :update, :id => buckets(:john_checking_general).id, :bucket => { :name => "Hi!" } 41 | assert_response :success 42 | assert_template "buckets/update.js.rjs" 43 | assert_equal subscriptions(:john), assigns(:subscription) 44 | assert_equal accounts(:john_checking), assigns(:account) 45 | assert_equal buckets(:john_checking_general), assigns(:bucket) 46 | assert_equal "Hi!", buckets(:john_checking_general, :reload).name 47 | end 48 | 49 | test "destroy without receiver_id should 404" do 50 | assert_no_difference "Bucket.count" do 51 | delete :destroy, :id => buckets(:john_checking_dining).id 52 | end 53 | assert_response :missing 54 | end 55 | 56 | test "destroy should assimilate line items and destroy bucket" do 57 | assert_difference "Bucket.count", -1 do 58 | delete :destroy, :id => buckets(:john_checking_dining).id, 59 | :receiver_id => buckets(:john_checking_groceries).id 60 | end 61 | assert_redirected_to(buckets(:john_checking_groceries)) 62 | assert !Bucket.exists?(buckets(:john_checking_dining).id) 63 | assert buckets(:john_checking_groceries), line_items(:john_lunch_checking_dining).bucket 64 | end 65 | 66 | # == API tests ======================================================================== 67 | 68 | test "index via API should return bucket list for account" do 69 | get :index, :account_id => accounts(:john_checking).id, :format => "xml" 70 | assert_response :success 71 | xml = Hash.from_xml(@response.body) 72 | assert_equal accounts(:john_checking).buckets.length, xml["buckets"].length 73 | end 74 | 75 | test "show via API should return bucket record" do 76 | get :show, :id => buckets(:john_checking_dining).id, :format => "xml" 77 | assert_response :success 78 | xml = Hash.from_xml(@response.body) 79 | assert_equal buckets(:john_checking_dining).id, xml["bucket"]["id"] 80 | end 81 | 82 | test "new via API should return a template XML response" do 83 | get :new, :account_id => accounts(:john_checking).id, :format => "xml" 84 | assert_response :success 85 | xml = Hash.from_xml(@response.body) 86 | assert xml["bucket"] 87 | assert !xml["bucket"]["id"] 88 | end 89 | 90 | test "create via API should return 422 with error messages when validations fail" do 91 | post :create, 92 | :account_id => accounts(:john_checking).id, 93 | :bucket => { :name => "Dining", :role => "" }, 94 | :format => "xml" 95 | assert_response :unprocessable_entity 96 | xml = Hash.from_xml(@response.body) 97 | assert xml["errors"].any? 98 | end 99 | 100 | test "create via API should create record and respond with 201" do 101 | assert_difference "accounts(:john_checking).buckets.count" do 102 | post :create, 103 | :account_id => accounts(:john_checking).id, 104 | :bucket => { :name => "Utilities", :role => "" }, 105 | :format => "xml" 106 | assert_response :created 107 | assert @response.headers["Location"] 108 | end 109 | end 110 | 111 | test "update via API should update record and respond with 200" do 112 | put :update, :id => buckets(:john_checking_dining).id, :bucket => { :name => "Hi!" }, :format => "xml" 113 | assert_response :success 114 | xml = Hash.from_xml(@response.body) 115 | assert_equal "Hi!", xml["bucket"]["name"] 116 | end 117 | 118 | test "update via API with validation errors should respond with 422" do 119 | put :update, :id => buckets(:john_checking_dining).id, :bucket => { :name => "Groceries" }, :format => "xml" 120 | assert_response :unprocessable_entity 121 | xml = Hash.from_xml(@response.body) 122 | assert xml["errors"].any? 123 | end 124 | 125 | test "destroy via API should remove record and respond with 200" do 126 | assert_difference "Bucket.count", -1 do 127 | delete :destroy, :id => buckets(:john_checking_dining).id, 128 | :receiver_id => buckets(:john_checking_groceries).id, 129 | :format => "xml" 130 | assert_response :success 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/functional/accounts_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class AccountsControllerTest < ActionController::TestCase 4 | setup :login_default_user 5 | 6 | test "show should 404 when when user without permissions requests page" do 7 | get :show, :id => accounts(:tim_checking).id 8 | assert_response :missing 9 | end 10 | 11 | test "new should 404 when user without permissions requests page" do 12 | get :new, :subscription_id => subscriptions(:tim).id 13 | assert_response :missing 14 | end 15 | 16 | test "create should 404 when when user without permissions requests page" do 17 | assert_no_difference "Account.count" do 18 | post :create, { 19 | :subscription_id => subscriptions(:tim).id, 20 | :account => { :name => "Savings", :role => "saving" } } 21 | assert_response :missing 22 | end 23 | end 24 | 25 | test "show should load account and subscription and render page" do 26 | get :show, :id => accounts(:john_checking).id 27 | assert_response :success 28 | assert_template "accounts/show" 29 | assert_equal accounts(:john_checking), assigns(:account) 30 | assert_equal subscriptions(:john), assigns(:subscription) 31 | end 32 | 33 | test "new should load subscription and render page" do 34 | get :new, :subscription_id => subscriptions(:john).id 35 | assert_response :success 36 | assert_template "accounts/new" 37 | assert_equal subscriptions(:john), assigns(:subscription) 38 | end 39 | 40 | test "create should load subscription and create account and redirect" do 41 | assert_difference "subscriptions(:john).accounts.count" do 42 | post :create, { 43 | :subscription_id => subscriptions(:john).id, 44 | :account => { :name => "Mortgage", :role => "" } } 45 | assert_redirected_to(subscription_url(subscriptions(:john))) 46 | end 47 | 48 | assert_equal subscriptions(:john), assigns(:subscription) 49 | assert assigns(:account) 50 | end 51 | 52 | test "create with invalid record should render 'new' action" do 53 | assert_no_difference "Account.count" do 54 | post :create, :subscription_id => subscriptions(:john).id, 55 | :account => { :name => "Checking", :role => "checking" } 56 | assert_response :success 57 | assert_template "accounts/new" 58 | assert assigns(:account) && !assigns(:account).valid? 59 | end 60 | end 61 | 62 | test "destroy should 404 when user without permission requests page" do 63 | assert_no_difference "Account.count" do 64 | delete :destroy, :id => accounts(:tim_checking).id 65 | assert_response :missing 66 | end 67 | end 68 | 69 | test "destroy should remove account and redirect" do 70 | assert_difference "Account.count", -1 do 71 | delete :destroy, :id => accounts(:john_mastercard).id 72 | assert_redirected_to(subscription_url(subscriptions(:john))) 73 | end 74 | end 75 | 76 | test "update should 404 when user without permissions requests page" do 77 | xhr :put, :update, :id => accounts(:tim_checking).id, :account => { :name => "Hi!" } 78 | assert_response :missing 79 | assert_equal "Checking", accounts(:tim_checking, :reload).name 80 | end 81 | 82 | test "update should change account name and render javascript" do 83 | xhr :put, :update, :id => accounts(:john_checking).id, :account => { :name => "Hi!" } 84 | assert_response :success 85 | assert_template "accounts/update.js.rjs" 86 | assert_equal subscriptions(:john), assigns(:subscription) 87 | assert_equal accounts(:john_checking), assigns(:account) 88 | assert_equal "Hi!", accounts(:john_checking, :reload).name 89 | end 90 | 91 | # == API tests ======================================================================== 92 | 93 | test "index via API should return account list" do 94 | get :index, :subscription_id => subscriptions(:john).id, :format => "xml" 95 | assert_response :success 96 | xml = Hash.from_xml(@response.body) 97 | assert_equal subscriptions(:john).accounts.length, xml["accounts"].length 98 | end 99 | 100 | test "show via API should return account record" do 101 | get :show, :id => accounts(:john_checking).id, :format => "xml" 102 | assert_response :success 103 | xml = Hash.from_xml(@response.body) 104 | assert_equal accounts(:john_checking).id, xml["account"]["id"] 105 | end 106 | 107 | test "new via API should return a template XML response" do 108 | get :new, :subscription_id => subscriptions(:john).id, :format => "xml" 109 | assert_response :success 110 | xml = Hash.from_xml(@response.body) 111 | assert xml["account"] 112 | assert !xml["account"]["id"] 113 | end 114 | 115 | test "create via API should return 422 with error messages when validations fail" do 116 | post :create, 117 | :subscription_id => subscriptions(:john).id, 118 | :account => { :name => "Checking", :role => "" }, 119 | :format => "xml" 120 | assert_response :unprocessable_entity 121 | xml = Hash.from_xml(@response.body) 122 | assert xml["errors"].any? 123 | end 124 | 125 | test "create via API should create record and respond with 201" do 126 | assert_difference "subscriptions(:john).accounts.count" do 127 | post :create, 128 | :subscription_id => subscriptions(:john).id, 129 | :account => { :name => "Mortgage", :role => "" }, 130 | :format => "xml" 131 | assert_response :created 132 | assert @response.headers["Location"] 133 | end 134 | end 135 | 136 | test "update via API with validation errors should respond with 422" do 137 | put :update, :id => accounts(:john_checking).id, :account => { :name => "Mastercard" }, :format => "xml" 138 | assert_response :unprocessable_entity 139 | xml = Hash.from_xml(@response.body) 140 | assert xml["errors"].any? 141 | end 142 | 143 | test "update via API should update record and respond with 200" do 144 | put :update, :id => accounts(:john_checking).id, :account => { :name => "Hi!" }, :format => "xml" 145 | assert_response :success 146 | xml = Hash.from_xml(@response.body) 147 | assert_equal "Hi!", xml["account"]["name"] 148 | end 149 | 150 | test "destroy via API should remove record and respond with 200" do 151 | assert_difference "Account.count", -1 do 152 | delete :destroy, :id => accounts(:john_mastercard).id, :format => "xml" 153 | assert_response :success 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /test/functional/tags_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TagsControllerTest < ActionController::TestCase 4 | setup :login_default_user 5 | 6 | test "show should 404 for invalid tag" do 7 | assert !Tag.exists?(1) 8 | get :show, :id => 1 9 | assert_response :missing 10 | end 11 | 12 | test "show should 404 for inaccessible tag" do 13 | get :show, :id => tags(:tim_milk).id 14 | assert_response :missing 15 | end 16 | 17 | test "show should display tag perma page for requested tag" do 18 | get :show, :id => tags(:john_lunch).id 19 | assert_response :success 20 | assert_template "tags/show" 21 | assert_equal tags(:john_lunch), assigns(:tag_ref) 22 | end 23 | 24 | test "update should 404 for inaccessible tag" do 25 | xhr :put, :update, :id => tags(:tim_milk).id, :tag => { :name => "hijacked!" } 26 | assert_response :missing 27 | assert_equal "milk", tags(:tim_milk, :reload).name 28 | end 29 | 30 | test "update should change tag name and render javascript response" do 31 | xhr :put, :update, :id => tags(:john_lunch).id, :tag => { :name => "hijacked!" } 32 | assert_response :success 33 | assert_template "tags/update.js.rjs" 34 | assert_equal "hijacked!", tags(:john_lunch, :reload).name 35 | end 36 | 37 | test "destroy should 404 for inaccessible tag" do 38 | assert_no_difference "Tag.count" do 39 | assert_no_difference "TaggedItem.count" do 40 | delete :destroy, :id => tags(:tim_milk).id 41 | assert_response :missing 42 | end 43 | end 44 | end 45 | 46 | test "destroy should remove tag and all associated tagged items" do 47 | item = tagged_items(:john_lunch_lunch) 48 | 49 | delete :destroy, :id => tags(:john_lunch).id 50 | assert_redirected_to subscription_url(subscriptions(:john)) 51 | 52 | assert !Tag.exists?(tags(:john_lunch).id) 53 | assert !TaggedItem.exists?(item.id) 54 | end 55 | 56 | test "merge should 404 when target tag is inaccessible" do 57 | assert_no_difference "Tag.count" do 58 | assert_no_difference "TaggedItem.count" do 59 | delete :destroy, :id => tags(:john_lunch).id, :receiver_id => tags(:tim_milk).id 60 | assert_response :missing 61 | end 62 | end 63 | end 64 | 65 | test "merge should 422 when target tag is same as deleted tag" do 66 | assert_no_difference "Tag.count" do 67 | assert_no_difference "TaggedItem.count" do 68 | delete :destroy, :id => tags(:john_lunch).id, :receiver_id => tags(:john_lunch).id 69 | assert_response :unprocessable_entity 70 | end 71 | end 72 | end 73 | 74 | test "merge should remove tag and move all associated tagged items to target tag" do 75 | balance = tags(:john_fuel).balance 76 | 77 | delete :destroy, :id => tags(:john_lunch).id, :receiver_id => tags(:john_fuel).id 78 | assert_redirected_to(tag_url(tags(:john_fuel))) 79 | 80 | assert !Tag.exists?(tags(:john_lunch).id) 81 | assert_equal tags(:john_fuel), tagged_items(:john_lunch_lunch).tag 82 | assert_equal balance + tagged_items(:john_lunch_lunch).amount, tags(:john_fuel, :reload).balance 83 | end 84 | 85 | # == API tests ======================================================================== 86 | 87 | test "index via API for inaccessible subscription should 404" do 88 | get :index, :subscription_id => subscriptions(:tim).id, :format => "xml" 89 | assert_response :missing 90 | end 91 | 92 | test "index via API should return list of all tags for given subscription" do 93 | get :index, :subscription_id => subscriptions(:john).id, :format => "xml" 94 | assert_response :success 95 | xml = Hash.from_xml(@response.body) 96 | assert xml.key?("tags") 97 | end 98 | 99 | test "show via API should return record for the given tag" do 100 | get :show, :id => tags(:john_tip).id, :format => "xml" 101 | assert_response :success 102 | xml = Hash.from_xml(@response.body) 103 | assert_equal tags(:john_tip).id, xml["tag"]["id"] 104 | end 105 | 106 | test "new via API should return template record" do 107 | get :new, :subscription_id => subscriptions(:john).id, :format => "xml" 108 | assert_response :success 109 | xml = Hash.from_xml(@response.body) 110 | assert xml.key?("tag") 111 | end 112 | 113 | test "create via API for inaccessible subscription should 404" do 114 | assert_no_difference "Tag.count" do 115 | post :create, :subscription_id => subscriptions(:tim).id, :format => "xml", 116 | :tag => { :name => "testing" } 117 | assert_response :missing 118 | end 119 | end 120 | 121 | test "create via API should return 201 and set location header" do 122 | assert_difference "Tag.count" do 123 | post :create, :subscription_id => subscriptions(:john).id, :format => "xml", 124 | :tag => { :name => "testing" } 125 | assert_response :success 126 | assert @response.headers['Location'] 127 | xml = Hash.from_xml(@response.body) 128 | assert xml.key?("tag") 129 | end 130 | end 131 | 132 | test "create via API should return 422 with errors if validations fail" do 133 | assert_no_difference "Tag.count" do 134 | post :create, :subscription_id => subscriptions(:john).id, :format => "xml", 135 | :tag => { :name => "tip" } 136 | assert_response :unprocessable_entity 137 | xml = Hash.from_xml(@response.body) 138 | assert xml.key?("errors") 139 | end 140 | end 141 | 142 | test "update via API for inaccessible tag should 404" do 143 | put :update, :id => tags(:tim_milk).id, :tag => { :name => "milkshake" }, :format => "xml" 144 | assert_response :missing 145 | assert_equal "milk", tags(:tim_milk, :reload).name 146 | end 147 | 148 | test "update via API should change tag name and return 200" do 149 | put :update, :id => tags(:john_tip).id, :tag => { :name => "gratuity" }, :format => "xml" 150 | assert_response :success 151 | xml = Hash.from_xml(@response.body) 152 | assert xml.key?("tag") 153 | assert_equal "gratuity", tags(:john_tip, :reload).name 154 | end 155 | 156 | test "update via API should return 422 with errors if validations fail" do 157 | put :update, :id => tags(:john_tip).id, :tag => { :name => "lunch" }, :format => "xml" 158 | assert_response :unprocessable_entity 159 | xml = Hash.from_xml(@response.body) 160 | assert xml.key?("errors") 161 | assert_equal "tip", tags(:john_tip, :reload).name 162 | end 163 | 164 | test "destroy via API should remove tag and return 200" do 165 | assert_difference "Tag.count", -1 do 166 | delete :destroy, :id => tags(:john_tip).id, :format => "xml" 167 | assert_response :success 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /public/mock/summary.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | Summary 8 | 9 | 10 | 11 | 12 |
13 |
14 |

Buckets

15 |

Record a new expense or deposit

16 |
17 | 18 | 71 | 72 |
73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
Groceries-$100.00
Household$200.17
Books$100.00
Auto:Fuel$250.00
Baby Supplies$145.45
see all 35 buckets
Vacation$200.00
General$1,034.56
87 |
88 |
89 | 90 | 91 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead of editing this file, 2 | # please use the migrations feature of Active Record to incrementally modify your database, and 3 | # then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your database schema. If you need 6 | # to create the application database on another system, you should be using db:schema:load, not running 7 | # all the migrations from scratch. The latter is a flawed and unsustainable approach (the more migrations 8 | # you'll amass, the slower it'll run and the greater likelihood for issues). 9 | # 10 | # It's strongly recommended to check this file into your version control system. 11 | 12 | ActiveRecord::Schema.define(:version => 20090421221109) do 13 | 14 | create_table "account_items", :force => true do |t| 15 | t.integer "event_id", :null => false 16 | t.integer "account_id", :null => false 17 | t.integer "amount", :null => false 18 | t.date "occurred_on", :null => false 19 | t.integer "statement_id" 20 | end 21 | 22 | add_index "account_items", ["account_id", "occurred_on"], :name => "index_account_items_on_account_id_and_occurred_on" 23 | add_index "account_items", ["event_id"], :name => "index_account_items_on_event_id" 24 | add_index "account_items", ["statement_id", "occurred_on"], :name => "index_account_items_on_statement_id_and_occurred_on" 25 | 26 | create_table "accounts", :force => true do |t| 27 | t.integer "subscription_id", :null => false 28 | t.integer "user_id", :null => false 29 | t.string "name", :null => false 30 | t.string "role" 31 | t.datetime "created_at" 32 | t.datetime "updated_at" 33 | t.integer "balance", :default => 0, :null => false 34 | end 35 | 36 | add_index "accounts", ["subscription_id", "name"], :name => "index_accounts_on_subscription_id_and_name", :unique => true 37 | 38 | create_table "buckets", :force => true do |t| 39 | t.integer "account_id", :null => false 40 | t.integer "user_id", :null => false 41 | t.string "name", :null => false 42 | t.string "role" 43 | t.datetime "created_at" 44 | t.datetime "updated_at" 45 | t.integer "balance", :default => 0, :null => false 46 | end 47 | 48 | add_index "buckets", ["account_id", "name"], :name => "index_buckets_on_account_id_and_name", :unique => true 49 | add_index "buckets", ["account_id", "updated_at"], :name => "index_buckets_on_account_id_and_updated_at" 50 | 51 | create_table "events", :force => true do |t| 52 | t.integer "subscription_id", :null => false 53 | t.integer "user_id", :null => false 54 | t.date "occurred_on", :null => false 55 | t.string "actor", :null => false 56 | t.integer "check_number" 57 | t.datetime "created_at" 58 | t.datetime "updated_at" 59 | t.text "memo" 60 | end 61 | 62 | add_index "events", ["subscription_id", "actor"], :name => "index_events_on_subscription_id_and_actor" 63 | add_index "events", ["subscription_id", "check_number"], :name => "index_events_on_subscription_id_and_check_number" 64 | add_index "events", ["subscription_id", "created_at"], :name => "index_events_on_subscription_id_and_created_at" 65 | add_index "events", ["subscription_id", "occurred_on"], :name => "index_events_on_subscription_id_and_occurred_on" 66 | 67 | create_table "line_items", :force => true do |t| 68 | t.integer "event_id", :null => false 69 | t.integer "account_id", :null => false 70 | t.integer "bucket_id", :null => false 71 | t.integer "amount", :null => false 72 | t.string "role", :limit => 20 73 | t.date "occurred_on", :null => false 74 | end 75 | 76 | add_index "line_items", ["account_id"], :name => "index_line_items_on_account_id" 77 | add_index "line_items", ["bucket_id", "occurred_on"], :name => "index_line_items_on_bucket_id_and_occurred_on" 78 | add_index "line_items", ["event_id"], :name => "index_line_items_on_event_id" 79 | 80 | create_table "statements", :force => true do |t| 81 | t.integer "account_id", :null => false 82 | t.date "occurred_on", :null => false 83 | t.integer "starting_balance" 84 | t.integer "ending_balance" 85 | t.datetime "balanced_at" 86 | t.datetime "created_at" 87 | t.datetime "updated_at" 88 | end 89 | 90 | add_index "statements", ["account_id", "occurred_on"], :name => "index_statements_on_account_id_and_occurred_on" 91 | 92 | create_table "subscriptions", :force => true do |t| 93 | t.integer "owner_id", :null => false 94 | end 95 | 96 | add_index "subscriptions", ["owner_id"], :name => "index_subscriptions_on_owner_id" 97 | 98 | create_table "tagged_items", :force => true do |t| 99 | t.integer "event_id", :null => false 100 | t.integer "tag_id", :null => false 101 | t.date "occurred_on", :null => false 102 | t.integer "amount", :null => false 103 | end 104 | 105 | add_index "tagged_items", ["event_id"], :name => "index_tagged_items_on_event_id" 106 | add_index "tagged_items", ["tag_id", "occurred_on"], :name => "index_tagged_items_on_tag_id_and_occurred_on" 107 | 108 | create_table "tags", :force => true do |t| 109 | t.integer "subscription_id", :null => false 110 | t.string "name", :null => false 111 | t.integer "balance", :default => 0, :null => false 112 | t.datetime "created_at" 113 | t.datetime "updated_at" 114 | end 115 | 116 | add_index "tags", ["subscription_id", "balance"], :name => "index_tags_on_subscription_id_and_balance" 117 | add_index "tags", ["subscription_id", "name"], :name => "index_tags_on_subscription_id_and_name", :unique => true 118 | 119 | create_table "user_subscriptions", :force => true do |t| 120 | t.integer "subscription_id", :null => false 121 | t.integer "user_id", :null => false 122 | t.datetime "created_at", :null => false 123 | end 124 | 125 | add_index "user_subscriptions", ["subscription_id", "user_id"], :name => "index_user_subscriptions_on_subscription_id_and_user_id", :unique => true 126 | add_index "user_subscriptions", ["user_id"], :name => "index_user_subscriptions_on_user_id" 127 | 128 | create_table "users", :force => true do |t| 129 | t.string "name", :null => false 130 | t.string "email", :null => false 131 | t.string "user_name" 132 | t.string "password_hash" 133 | t.string "salt" 134 | t.datetime "created_at" 135 | t.datetime "updated_at" 136 | end 137 | 138 | add_index "users", ["user_name"], :name => "index_users_on_user_name", :unique => true 139 | 140 | end 141 | --------------------------------------------------------------------------------