├── 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 |
17 |
18 |
71 |
72 |
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 |
--------------------------------------------------------------------------------