├── public ├── favicon.ico ├── blank_iframe.html ├── images │ ├── success.png │ ├── success16.png │ └── calendar_date_select ├── javascripts │ ├── calendar_date_select │ ├── application.js │ ├── filters.js │ ├── tags.js │ ├── buckets.js │ ├── money.js │ ├── statements.js │ └── accounts.js ├── stylesheets │ └── calendar_date_select ├── robots.txt ├── 422.html ├── 404.html └── 500.html ├── vendor ├── .gitignore └── plugins │ ├── cached_externals │ ├── .gitignore │ ├── lib │ │ ├── cached_externals │ │ │ └── git-hooks │ │ │ │ ├── post-merge │ │ │ │ └── post-checkout │ │ └── tasks │ │ │ └── git.rake │ ├── recipes │ │ └── cached_externals.rb │ ├── cached_externals.gemspec │ ├── LICENSE │ └── 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 │ │ ├── create.js.rjs │ │ ├── _expand.html.haml │ │ ├── _form_reallocate.html.haml │ │ ├── destroy.js.rjs │ │ ├── _row.html.haml │ │ ├── _form_tags.html.haml │ │ ├── _form.html.haml │ │ ├── _form_section.html.haml │ │ └── _form_general.html.haml │ ├── accounts │ │ ├── new.html.haml │ │ ├── update.js.rjs │ │ ├── show.html.haml │ │ ├── _account.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 │ │ ├── _bucket_name_for_index.html.haml │ │ ├── _delete_form.html.haml │ │ ├── show.html.haml │ │ └── index.html.haml │ ├── sessions │ │ └── new.html.haml │ └── layouts │ │ └── application.html.haml ├── models │ ├── user_subscription.rb │ ├── actor.rb │ ├── tag.rb │ ├── user.rb │ ├── tagged_item.rb │ ├── line_item.rb │ ├── account_item.rb │ ├── query_filter.rb │ ├── statement.rb │ ├── bucket.rb │ ├── subscription.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 │ ├── actors.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 ├── 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 ├── config ├── initializers │ ├── calendar_date_select.rb │ ├── activerecord.rb │ ├── redirect_log.rb │ └── new_rails_defaults.rb ├── session.yml ├── deploy.rb ├── database.yml ├── routes.rb ├── environment.rb ├── externals.yml ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb └── boot.rb ├── config.ru ├── lib ├── bucket_wise │ └── version.rb ├── tasks │ ├── subscription.rake │ ├── data.rake │ └── user.rake └── populator.rb ├── db └── migrate │ ├── 20090330132556_add_memo_field_to_event.rb │ ├── 20090516072907_add_limit_to_accounts.rb │ ├── 20090404154634_cache_balances_on_buckets_and_accounts.rb │ ├── 20090421221109_add_statements.rb │ ├── 20090506161959_normalize_actors.rb │ └── 20080513032848_initial_schema.rb ├── Rakefile ├── CHANGELOG.rdoc ├── 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 -------------------------------------------------------------------------------- /public/images/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamis/bucketwise/HEAD/public/images/success.png -------------------------------------------------------------------------------- /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/success16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamis/bucketwise/HEAD/public/images/success16.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/lib/cached_externals/git-hooks/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -f config/externals.yml ]; 4 | then 5 | cap -q local externals:setup 6 | fi; 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/config/environment' 2 | 3 | if Rails.env.development? 4 | use Rails::Rack::Static 5 | end 6 | 7 | run ActionController::Dispatcher.new 8 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/recipes/cached_externals.rb: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../../lib', __FILE__) 2 | $:.unshift(lib) unless $:.include?(lib) 3 | require 'cached_externals' 4 | -------------------------------------------------------------------------------- /config/session.yml: -------------------------------------------------------------------------------- 1 | :key: _bucketwise_session 2 | :secret: "2f8e38ad3db3c9c3be9e60866b6d9137919c9ffb96431d6c0620217fe99fae08b1860b13a133dccd953192cc6b3dfb232031a00a6baa6cf7ce70edde39db5b09" 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/lib/cached_externals/git-hooks/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ "1" == $3 && -f config/externals.yml ]]; 4 | then 5 | cap -q local externals:setup 6 | fi; 7 | -------------------------------------------------------------------------------- /lib/bucket_wise/version.rb: -------------------------------------------------------------------------------- 1 | module BucketWise 2 | module Version 3 | MAJOR = 1 4 | MINOR = 1 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /db/migrate/20090516072907_add_limit_to_accounts.rb: -------------------------------------------------------------------------------- 1 | class AddLimitToAccounts < ActiveRecord::Migration 2 | def self.up 3 | add_column :accounts, :limit, :integer 4 | end 5 | 6 | def self.down 7 | remove_column :accounts, :limit 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/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 | -------------------------------------------------------------------------------- /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/_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 | = link_to_function "[x]", "Events.removeLineItem(this.up('li'))" 6 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /public/javascripts/filters.js: -------------------------------------------------------------------------------- 1 | var Filters = { 2 | display: function() { 3 | var form = $('filter_form'); 4 | var nub = $('filter_nubbin'); 5 | 6 | //form.style.right = (nub.offsetLeft + nub.getWidth()) + "px"; 7 | form.show(); 8 | form.style.left = (nub.offsetLeft + nub.getWidth() - form.getWidth()) + "px"; 9 | }, 10 | 11 | hide: function() { 12 | $('filter_form').hide(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/lib/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/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 | - if account.buckets.length > 1 5 | - reset_cycle 6 | = render(account.buckets.recent) 7 | - if account.buckets.length > Bucket::RECENT_WINDOW_SIZE 8 | %tr.bucket 9 | %td.more{:colspan => 2} 10 | → 11 | = link_to "See all #{account.buckets.length} buckets", account_buckets_path(account) 12 | -------------------------------------------------------------------------------- /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.autocompleteTagField('#{dom_id(tagged_item, :tag_name)}'); 7 | = link_to_function "[x]", "Events.removeTaggedItem(this.up('li'))" 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vendor/plugins/cached_externals/cached_externals.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'cached_externals' 3 | s.version = '1.0.0' 4 | s.date = '2010-03-29' 5 | s.summary = 'Symlink to external dependencies, rather than bloating your repositories with them' 6 | s.description = s.summary 7 | 8 | s.add_dependency('capistrano') 9 | 10 | s.files = Dir['lib/**/*'] 11 | 12 | s.author = 'Jamis Buck' 13 | s.email = 'jamis@jamisbuck.org' 14 | s.homepage = 'http://github.com/37signals/cached_externals' 15 | end 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/models/actor.rb: -------------------------------------------------------------------------------- 1 | class Actor < ActiveRecord::Base 2 | belongs_to :subscription 3 | has_many :events 4 | 5 | validates_presence_of :name, :sort_name 6 | attr_accessible :name, :sort_name 7 | 8 | def self.normalize_name(name) 9 | name.strip.upcase 10 | end 11 | 12 | def self.normalize(name) 13 | name = name.strip 14 | sort_name = normalize_name(name) 15 | 16 | actor = find_by_sort_name(sort_name) 17 | if actor 18 | actor.ping! 19 | return actor 20 | end 21 | 22 | create(:sort_name => sort_name, :name => name) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/views/subscriptions/_blank_slate.html.haml: -------------------------------------------------------------------------------- 1 | %h2 Welcome to BucketWise! 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 | -------------------------------------------------------------------------------- /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.with_options :controller => "subscriptions", :action => "index" do |home| 12 | home.root 13 | home.connect "" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /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/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 << emit_account_data_assignments 16 | page.visual_effect :highlight, dom_id(event) 17 | end 18 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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.autoload_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.11 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/haml/haml.git 12 | :revision: 3.0.25 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: 35d5c39c367cb94e5bfdcd80b063a4cf1bd3885d 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_name 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/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/sessions/new.html.haml: -------------------------------------------------------------------------------- 1 | !!! 2 | %html 3 | %head 4 | %meta{"http-equiv" => "Content-Type", :content => "text/html; charset=utf-8"}/ 5 | %title Log into BucketWise 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 BucketWise" 23 | 24 | :javascript 25 | window.onload = function() { $('user_name').focus(); } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/views/accounts/_name.html.haml: -------------------------------------------------------------------------------- 1 | %span.actions 2 | - if account.credit_card? 3 | = link_to_function("Adjust Limit", "Accounts.adjustLimit(#{account_path(account).to_json}, #{account.limit.to_json}, #{form_authenticity_token.to_json})") 4 | | 5 | - if account.statements.pending.any? 6 | = link_to("Resume reconciling", edit_statement_path(account.statements.pending.first)) 7 | - else 8 | = link_to("Reconcile", new_account_statement_path(account)) 9 | | 10 | - if account.statements.balanced.any? 11 | = link_to("Prior statements", account_statements_path(account)) 12 | | 13 | = link_to_function("Rename", "Accounts.rename(#{account_path(account).to_json}, #{account.name.to_json}, #{form_authenticity_token.to_json})") 14 | | 15 | = link_to("Delete", account_path(account), :method => :delete, :confirm => "Are you sure you want to delete this account?") 16 | &= account.name 17 | -------------------------------------------------------------------------------- /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/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/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 BucketWise 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 "log out", session_path, :method => :delete 18 | %span.title 19 | = link_to "BucketWise", root_path 20 | 21 | #container 22 | = yield 23 | #version 24 | == BucketWise version: v#{BucketWise::Version::STRING} (rev #{application_revision}) 25 | %br 26 | == Last deployed: #{application_last_deployed} 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_name => "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_name => "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 | -------------------------------------------------------------------------------- /db/migrate/20090506161959_normalize_actors.rb: -------------------------------------------------------------------------------- 1 | class NormalizeActors < ActiveRecord::Migration 2 | def self.up 3 | rename_column :events, :actor, :actor_name 4 | add_column :events, :actor_id, :integer 5 | add_index :events, :actor_id 6 | 7 | create_table :actors do |t| 8 | t.integer :subscription_id, :null => false 9 | t.string :name, :null => false 10 | t.string :sort_name, :null => false 11 | t.timestamps 12 | end 13 | 14 | add_index :actors, %w(subscription_id sort_name), :unique => true 15 | add_index :actors, %w(subscription_id updated_at) 16 | 17 | say_with_time "normalizing all existing event actors" do 18 | Event.find_each(:include => :subscription) do |event| 19 | say "normalizing event #{event.id}: #{event.actor_name.inspect}" 20 | event.update_attribute :actor, event.subscription.actors.normalize(event.actor_name) 21 | end 22 | end 23 | end 24 | 25 | def self.down 26 | drop_table :actors 27 | 28 | remove_index :events, :actor_id 29 | remove_column :events, :actor_id 30 | rename_column :events, :actor_name, :actor 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /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_name 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 | -------------------------------------------------------------------------------- /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 | limit: 5000 21 | created_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 22 | updated_at: <%= (60.days.ago + 2.hours).utc.to_s(:db) %> 23 | 24 | john_savings: 25 | subscription: john 26 | author: john 27 | name: Savings 28 | role: ~ 29 | balance: 0 30 | created_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %> 31 | updated_at: <%= (60.days.ago + 3.hours).utc.to_s(:db) %> 32 | 33 | # -------------------------------------------------------------- 34 | # tim 35 | # -------------------------------------------------------------- 36 | 37 | tim_checking: 38 | subscription: tim 39 | author: tim 40 | name: Checking 41 | role: checking 42 | balance: 125000 43 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 44 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 45 | -------------------------------------------------------------------------------- /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.autocomplete_select{:style => "display: none"} 16 | :javascript 17 | Events.autocompleteTagField('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/views/events/_form.html.haml: -------------------------------------------------------------------------------- 1 | = javascript_tag(emit_account_data_assignments) 2 | 3 | #templates{:style => "display: none"} 4 | - form_sections.each do |section| 5 | %div{:id => "template.#{section}"} 6 | = render :partial => template_partial_for(section), :locals => { :section => section } 7 | 8 | - form_for(:event, event_for_form, :url => event_form_action, :html => { :id => "event_form", :onsubmit => "Events.submit(this); return false" }) do |form| 9 | #success_notice.notice{:style => "display: none"} 10 | %p== Your transaction has been recorded. You may enter another one, | 11 | or #{link_to_function "close this form", "Events.cancel()"}. | 12 | 13 | - if event_wants_section?('general_information') 14 | = render :partial => "events/form_general", :locals => { :form => form } 15 | - else 16 | :javascript 17 | Events.defaultDate = #{@event.occurred_on.strftime("%Y-%m-%d").to_json}; 18 | Events.defaultActor = #{@event.actor_name.to_json}; 19 | 20 | - form_sections.each do |section| 21 | = render_event_form_section(form, section) if event_wants_section?(section) 22 | 23 | %p 24 | - if !event_for_form.new_record? 25 | %input{:type => "submit", :value => "Edit this transaction"}/ 26 | - else 27 | %input{:type => "submit", :value => "Record this transaction"}/ 28 | or 29 | = link_to_function "cancel", "Events.cancel()" 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 | -------------------------------------------------------------------------------- /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/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 | delegate :name, :to => :tag 13 | 14 | def tag_id=(value) 15 | case value 16 | when Fixnum, /^\s*\d+\s*$/ then super(value) 17 | else @tag_to_translate = value 18 | end 19 | end 20 | 21 | def to_xml(options={}) 22 | options[:except] = Array(options[:except]) 23 | options[:except].concat [:event_id, :occurred_on] 24 | options[:except] << :tag_id unless new_record? 25 | 26 | append_to_options(options, :include, :tag => { :except => :subscription_id }) 27 | super(options) 28 | end 29 | 30 | protected 31 | 32 | def ensure_consistent_tag 33 | if @tag_to_translate =~ /^n:(.*)/ 34 | self.tag_id = event.subscription.tags.find_or_create_by_name($1).id 35 | else 36 | # make sure the given tag id exists in the given subscription 37 | event.subscription.tags.find(tag_id) 38 | end 39 | end 40 | 41 | def ensure_occurred_on 42 | self.occurred_on ||= event.occurred_on 43 | end 44 | 45 | def increment_tag_balance 46 | Tag.connection.update "UPDATE tags SET balance = balance + #{amount} WHERE id = #{tag_id}" 47 | end 48 | 49 | def decrement_tag_balance 50 | Tag.connection.update "UPDATE tags SET balance = balance - #{amount} WHERE id = #{tag_id}" 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /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 | classes << "current_balance" 16 | 17 | if container.is_a?(Account) && container.credit_card? && !container.limit.blank? 18 | percentage_used = container.limit.abs.to_i == 0 ? 100 : 19 | ((container.balance.abs.to_f / container.limit.abs.to_f) * 100).to_i 20 | classes << if percentage_used >= Account::DEFAULT_LIMIT_VALUES[:critical]: "critical" 21 | elsif percentage_used >= Account::DEFAULT_LIMIT_VALUES[:high]: "high" 22 | elsif percentage_used >= Account::DEFAULT_LIMIT_VALUES[:medium]: "medium" 23 | else "low" end 24 | end 25 | 26 | content = format_amount(balance) 27 | if real_balance != balance 28 | content = "(" << format_amount(real_balance) << ") #{content}" 29 | end 30 | 31 | content_tag(options.fetch(:tag, "td"), content, :class => classes.join(" "), :id => options[:id]) 32 | end 33 | 34 | def format_amount(amount) 35 | amount = amount.abs 36 | 37 | dollars = amount / 100 38 | cents = amount % 100 39 | 40 | "$%s.%02d" % [number_with_delimiter(dollars), cents] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | = link_to_function(filter, "Filters.display()", :id => :filter_nubbin) 14 | 15 | #filter_form{:style => "display: none"} 16 | - form_tag(account_buckets_path(account), :method => :get) do 17 | %p 18 | Include: 19 | %label 20 | = check_box_tag(:expenses, "1", filter.expenses?) 21 | Expenses 22 | = "|" 23 | %label 24 | = check_box_tag(:deposits, "1", filter.deposits?) 25 | Deposits 26 | = "|" 27 | %label 28 | = check_box_tag(:reallocations, "1", filter.reallocations?) 29 | Reallocations 30 | %p 31 | %label 32 | From: 33 | = calendar_date_select_tag :from, filter.from, :size => 9 34 | %label 35 | To: 36 | = calendar_date_select_tag :to, filter.to, :size => 9 37 | 38 | %p.actions 39 | = submit_tag "Apply these criteria", :name => nil 40 | or 41 | = link_to_function "cancel", "Filters.hide()" 42 | - if filter.any? 43 | or 44 | = link_to("reset this filter", account_buckets_path(account)) 45 | 46 | %table#accounts_summary 47 | = render(buckets.sort_by(&:name)) 48 | -------------------------------------------------------------------------------- /CHANGELOG.rdoc: -------------------------------------------------------------------------------- 1 | === (unreleased) 2 | 3 | * Fix rounding error on ending balance for new statements [Cody Maggard] 4 | 5 | * Track limit on credit card accounts [Kieran Pilkington] 6 | 7 | * Change text on "save" button in event form to reflect edit vs. new [Kieran Pilkington] 8 | 9 | * Make "BucketWise" in header a link to the root path [Kieran Pilkington] 10 | 11 | 12 | === 1.1.0 / 14 May 2009 13 | 14 | * Recall previously entered transactions for easy entry of similar events [Jamis Buck] 15 | 16 | * Hide bucket list if there are less than 2 buckets for an account [Jamis Buck] 17 | 18 | * Increase "recent buckets" window size to 10 (from 5) [Jamis Buck] 19 | 20 | * Actor name autocompletion in forms [Jamis Buck] 21 | 22 | * Don't save line-items if they are blank [Jamis Buck] 23 | 24 | * Allow bucket-reallocation line items to be removed [Jamis Buck] 25 | 26 | * Show bucket split option when editing single-line-item events [Jamis Buck] 27 | 28 | * Fixed bug that wouldn't let you have a negative starting balance for new accounts [Jamis Buck] 29 | 30 | * Fixed bug that included an implicit "aside" bucket in the total bucket count [Tony Eichelberger] 31 | 32 | * Simple budget reporting [Jamis Buck] 33 | 34 | * Fix bug in account deletion that would leave (e.g.) transfer line items in an inconsistent state [Jamis Buck] 35 | 36 | * Account reconciliation [Jamis Buck] 37 | 38 | * filter password from logs [Jordan Brough] 39 | 40 | * fix incorrect rails gem reference in config/environment.rb [Jamis Buck] 41 | 42 | * fix reference to ~/.bucketwise/Capfile so it only loads if the file exists [Jamis Buck] 43 | 44 | 45 | === 1.0.0 / 20 Apr 2009 46 | 47 | * First release 48 | -------------------------------------------------------------------------------- /test/fixtures/actors.yml: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------- 2 | # john 3 | # -------------------------------------------------------------- 4 | 5 | john_starting_balance: 6 | subscription: john 7 | name: Starting balance 8 | sort_name: STARTING BALANCE 9 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 10 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 11 | 12 | john_sandwich_central: 13 | subscription: john 14 | name: Sandwich Central 15 | sort_name: SANDWICH CENTRAL 16 | created_at: <%= 59.days.ago.utc.to_s(:db) %> 17 | updated_at: <%= 55.days.ago.utc.to_s(:db) %> 18 | 19 | john_mastercard_co: 20 | subscription: john 21 | name: MasterCard Co. 22 | sort_name: MASTERCARD CO. 23 | created_at: <%= 58.days.ago.utc.to_s(:db) %> 24 | updated_at: <%= 58.days.ago.utc.to_s(:db) %> 25 | 26 | john_bucket_reallocation: 27 | subscription: john 28 | name: Bucket reallocation 29 | sort_name: BUCKET REALLOCATION 30 | created_at: <%= 57.days.ago.utc.to_s(:db) %> 31 | updated_at: <%= 56.days.ago.utc.to_s(:db) %> 32 | 33 | john_auto_fuel: 34 | subscription: john 35 | name: Auto fuel 36 | sort_name: AUTO FUEL 37 | created_at: <%= 54.days.ago.utc.to_s(:db) %> 38 | updated_at: <%= 54.days.ago.utc.to_s(:db) %> 39 | 40 | # -------------------------------------------------------------- 41 | # tim 42 | # -------------------------------------------------------------- 43 | 44 | tim_starting_balance: 45 | subscription: tim 46 | name: Starting balance 47 | sort_name: STARTING BALANCE 48 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 49 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 50 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 Who received the payment? 12 | %span.deposit_label Where did this deposit come from? 13 | %span.transfer_label What was this transfer for? 14 | = form.text_field :actor_name, :size => 30 15 | - if form.object.new_record? 16 | %span#recall_event{:style => "display: none"}= link_to_function "(recall)", "Events.recallEvent(#{subscription_events_path(subscription).to_json})" 17 | 18 | #event_actor_name_select.autocomplete_select{:style => "display: none"} 19 | = javascript_tag "Events.autocompleteActorField()" 20 | 21 | %p 22 | %span.expense_label How much was paid? 23 | %span.deposit_label How much was deposited? 24 | %span.transfer_label How much was transferred? 25 | == $#{text_field_tag :amount, event_amount_value, :size => 8, :id => "expense_total", :class => "number", :onchange => "Events.updateUnassigned()"} 26 | 27 | %p#memo_link{:style => visible?(!event_wants_memo?)} 28 | Got more to say? 29 | = link_to_function "Add a more verbose description...", "Events.revealMemo()" 30 | 31 | %p#memo{:style => visible?(event_wants_memo?)} 32 | Describe this transaction: 33 | %br 34 | = form.text_area :memo, :rows => 2, :cols => 70 35 | -------------------------------------------------------------------------------- /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 | * After adding an event with a new actor, the actor autocompletion won't pick up the new actor until the page is reloaded. 7 | * Merging one bucket into another can result in events having two line-items referencing the same bucket. 8 | 9 | -------------------------------------------------------------------------- 10 | FEATURES that would be nice to have someday (in no particular order) 11 | -------------------------------------------------------------------------- 12 | 13 | * spinner for ajax actions 14 | * user info page (for password change) 15 | * password reset from login page 16 | * signup process 17 | * exception notifier (?) 18 | * better /subscriptions index 19 | * better 404 and 500 error pages 20 | * searching 21 | * reporting 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 | * full-text searching on the memo field 28 | * show bucket and account balances next to names in drop-downs 29 | * make bucket reallocation work from bucket perma 30 | * support for multiple 'aside' buckets in a single account 31 | * graphical icons to replace the textual icons for various actions 32 | * add/edit transactions from the reconciliation view 33 | * statement API 34 | * show percentage of credit limit consumed, rather than simply using text color 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/models/query_filter.rb: -------------------------------------------------------------------------------- 1 | class QueryFilter 2 | attr_reader :from 3 | attr_reader :to 4 | 5 | def initialize(options={}) 6 | @has_from = @has_to = false 7 | 8 | begin 9 | @from = Date.parse(options[:from]) 10 | @has_from = true 11 | rescue 12 | @from = 3.months.ago.to_date 13 | end 14 | 15 | begin 16 | @to = Date.parse(options[:to]) 17 | @has_to = true 18 | rescue 19 | @to = Date.today 20 | end 21 | 22 | @expenses = options[:expenses] 23 | @deposits = options[:deposits] 24 | @reallocations = options[:reallocations] 25 | 26 | if !@expenses && !@deposits && !@reallocations 27 | @expenses = @deposits = @reallocations = true 28 | end 29 | end 30 | 31 | def by_date? 32 | from? || to? 33 | end 34 | 35 | def from? 36 | @has_from 37 | end 38 | 39 | def to? 40 | @has_to 41 | end 42 | 43 | def by_type? 44 | !expenses? || !deposits? || !reallocations? 45 | end 46 | 47 | def expenses? 48 | @expenses 49 | end 50 | 51 | def deposits? 52 | @deposits 53 | end 54 | 55 | def reallocations? 56 | @reallocations 57 | end 58 | 59 | def any? 60 | by_date? || by_type? 61 | end 62 | 63 | def to_s 64 | description = "Filter" 65 | 66 | if any? 67 | description << ": " 68 | 69 | parts = [ 70 | expenses? && "expenses", 71 | deposits? && "deposits", 72 | reallocations? && "reallocations" 73 | ].compact 74 | 75 | if parts.length == 3 76 | description << "all transactions" 77 | else 78 | description << parts.to_sentence 79 | end 80 | 81 | description << " from " << from.strftime("%Y-%m-%d") if from? 82 | description << " to " << to.strftime("%Y-%m-%d") if to? 83 | end 84 | 85 | return description 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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"]], {}, :onchange => "Accounts.showOrHideCreditLimit(this.value);" 22 | 23 | %p{:style => 'display: none;', :id => 'credit_limit_div'} 24 | %label 25 | What is the credit limit: 26 | == $#{form.text_field :limit, :class => "number", :size => 8} 27 | 28 | - if form.object.nil? || form.object.new_record? 29 | %fieldset 30 | %legend Starting balance 31 | 32 | .instructions 33 | %p Take a look at your latest statement for this account. 34 | %p Note the current balance from the statement, as | 35 | well as the date the statement was issued, and enter | 36 | them below. | 37 | = hidden_field_tag "account[starting_balance][amount]", "" 38 | 39 | %p 40 | %label 41 | What is the current balance: 42 | == $#{text_field_tag :current_balance, account_starting_balance_amount, :class => "number", :size => 8} 43 | 44 | %p 45 | %label 46 | When was the statement issued: 47 | = calendar_date_select_tag "account[starting_balance][occurred_on]", account_starting_balance_occurred_on, :size => 9 48 | 49 | %p 50 | = submit_tag "Create this account" 51 | or 52 | = link_to_function "cancel", "Accounts.hideForm()" 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/tasks/data.rake: -------------------------------------------------------------------------------- 1 | namespace :data do 2 | namespace :subscription do 3 | desc "Dump all data for a single subscription" 4 | task :dump => :environment do 5 | id = ENV['ID'] or abort "please specify the subscription id via the ID env var" 6 | subscription = Subscription.find(id) 7 | 8 | data = {} 9 | 10 | data[:tags] = subscription.tags.map(&:attributes) 11 | data[:accounts] = subscription.accounts.map(&:attributes) 12 | data[:actors] = subscription.actors.map(&:attributes) 13 | data[:events] = subscription.events.map(&:attributes) 14 | data[:user_subscriptions] = subscription.user_subscriptions.map(&:attributes) 15 | 16 | data[:statements] = subscription.accounts.map(&:statements).flatten.map(&:attributes) 17 | data[:buckets] = subscription.accounts.map(&:buckets).flatten.map(&:attributes) 18 | data[:line_items] = subscription.events.map(&:line_items).flatten.map(&:attributes) 19 | data[:account_items] = subscription.events.map(&:account_items).flatten.map(&:attributes) 20 | data[:tagged_items] = subscription.events.map(&:tagged_items).flatten.map(&:attributes) 21 | 22 | data[:subscription] = subscription.attributes 23 | 24 | File.open("#{id}.yml", "w") { |f| f.write(data.to_yaml) } 25 | end 26 | 27 | desc "Restores data for the given subscription file" 28 | task :load => :environment do 29 | abort "please confirm (via the CONFIRM env var) that you really want to do this" unless ENV['CONFIRM'] 30 | 31 | file = ENV['FILE'] or abort "please specify the dump file via the FILE env var" 32 | data = YAML.load_file(file) 33 | 34 | insert = Proc.new do |table, record| 35 | c = ActiveRecord::Base.connection 36 | columns = record.keys.map { |name| c.quote_column_name(name) } 37 | values = record.values.map { |value| c.quote(value) } 38 | c.insert("INSERT INTO #{table} (#{columns.join(",")}) VALUES (#{values.join(",")})") 39 | end 40 | 41 | Subscription.transaction do 42 | subscription = Subscription.find_by_id(data[:subscription]['id']) 43 | subscription.destroy if subscription 44 | 45 | insert.call("subscriptions", data[:subscription]) 46 | %w(tags accounts actors events user_subscriptions statements buckets line_items account_items tagged_items).each do |table| 47 | Array(data[table.to_sym]).each do |record| 48 | insert.call(table, record) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | @filter = QueryFilter.new(params) 9 | @buckets = account.buckets.filter(@filter) 10 | 11 | respond_to do |format| 12 | format.html 13 | format.xml { render :xml => @buckets.to_xml(eager_options(:root => "buckets")) } 14 | end 15 | end 16 | 17 | def show 18 | respond_to do |format| 19 | format.html do 20 | @page = (params[:page] || 0).to_i 21 | @more_pages, @items = bucket.line_items.page(@page) 22 | end 23 | 24 | format.xml { render :xml => bucket.to_xml(eager_options) } 25 | end 26 | end 27 | 28 | def new 29 | respond_to { |format| format.xml { render :xml => Bucket.template.to_xml } } 30 | end 31 | 32 | def create 33 | respond_to do |format| 34 | format.xml do 35 | @bucket = account.buckets.create!(params[:bucket], :author => user) 36 | render :status => :created, :xml => @bucket.to_xml, :location => bucket_url(@bucket) 37 | end 38 | end 39 | rescue ActiveRecord::RecordInvalid => error 40 | @bucket = error.record 41 | respond_to do |format| 42 | format.xml { render :status => :unprocessable_entity, :xml => @bucket.errors.to_xml } 43 | end 44 | end 45 | 46 | def update 47 | bucket.update_attributes!(params[:bucket]) 48 | 49 | respond_to do |format| 50 | format.js 51 | format.xml { render :xml => bucket.to_xml } 52 | end 53 | rescue ActiveRecord::RecordInvalid 54 | respond_to do |format| 55 | format.js 56 | format.xml { render :status => :unprocessable_entity, :xml => bucket.errors.to_xml } 57 | end 58 | end 59 | 60 | def destroy 61 | receiver = account.buckets.find(params[:receiver_id]) 62 | receiver.assimilate(bucket) 63 | 64 | respond_to do |format| 65 | format.html { redirect_to(receiver) } 66 | format.xml { head :ok } 67 | end 68 | end 69 | 70 | protected 71 | 72 | attr_reader :account, :bucket, :buckets, :filter 73 | helper_method :account, :bucket, :buckets, :filter 74 | 75 | def find_account 76 | @account = Account.find(params[:account_id]) 77 | @subscription = user.subscriptions.find(@account.subscription_id) 78 | end 79 | 80 | def find_bucket 81 | @bucket = Bucket.find(params[:id]) 82 | @account = @bucket.account 83 | @subscription = user.subscriptions.find(@account.subscription_id) 84 | end 85 | 86 | def current_location 87 | if bucket 88 | "buckets/%d" % bucket.id 89 | else 90 | super 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /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 | 69 | test "balance should read computed_balance if that value is set" do 70 | filter = QueryFilter.new(:expenses => true) 71 | dining = accounts(:john_checking).buckets.filter(filter).find(:first, :conditions => { :name => "Dining" }) 72 | assert dining[:computed_balance] 73 | assert_not_equal dining[:balance], dining[:computed_balance] 74 | assert_equal dining[:computed_balance].to_i, dining.balance 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /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: john_starting_balance 10 | actor_name: Starting balance 11 | check_number: ~ 12 | created_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 13 | updated_at: <%= (60.days.ago + 1.hour).utc.to_s(:db) %> 14 | 15 | john_lunch: 16 | subscription: john 17 | user: john 18 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %> 19 | actor_name: Sandwich Central 20 | actor: john_sandwich_central 21 | check_number: ~ 22 | created_at: <%= 59.days.ago.utc.to_s(:db) %> 23 | updated_at: <%= 59.days.ago.utc.to_s(:db) %> 24 | 25 | john_bill_pay: 26 | subscription: john 27 | user: john 28 | occurred_on: <%= 58.days.ago.to_date.to_s(:db) %> 29 | actor_name: MasterCard Co. 30 | actor: john_mastercard_co 31 | check_number: 1234 32 | created_at: <%= 58.days.ago.utc.to_s(:db) %> 33 | updated_at: <%= 58.days.ago.utc.to_s(:db) %> 34 | 35 | john_reallocate_from: 36 | subscription: john 37 | user: john 38 | occurred_on: <%= 57.days.ago.to_date.to_s(:db) %> 39 | actor_name: Bucket reallocation 40 | actor: john_bucket_reallocation 41 | check_number: ~ 42 | created_at: <%= 57.days.ago.utc.to_s(:db) %> 43 | updated_at: <%= 57.days.ago.utc.to_s(:db) %> 44 | 45 | john_reallocate_to: 46 | subscription: john 47 | user: john 48 | occurred_on: <%= 56.days.ago.to_date.to_s(:db) %> 49 | actor_name: Bucket reallocation 50 | actor: john_bucket_reallocation 51 | check_number: ~ 52 | created_at: <%= 56.days.ago.utc.to_s(:db) %> 53 | updated_at: <%= 56.days.ago.utc.to_s(:db) %> 54 | 55 | john_lunch_again: 56 | subscription: john 57 | user: john 58 | occurred_on: <%= 55.days.ago.to_date.to_s(:db) %> 59 | actor_name: Sandwich Central 60 | actor: john_sandwich_central 61 | check_number: ~ 62 | created_at: <%= 55.days.ago.utc.to_s(:db) %> 63 | updated_at: <%= 55.days.ago.utc.to_s(:db) %> 64 | 65 | john_bare_mastercard: 66 | subscription: john 67 | user: john 68 | occurred_on: <%= 54.days.ago.to_date.to_s(:db) %> 69 | actor_name: Auto fuel 70 | actor: john_auto_fuel 71 | check_number: ~ 72 | created_at: <%= 54.days.ago.utc.to_s(:db) %> 73 | updated_at: <%= 54.days.ago.utc.to_s(:db) %> 74 | 75 | # -------------------------------------------------------------- 76 | # tim 77 | # -------------------------------------------------------------- 78 | 79 | tim_checking_starting_balance: 80 | subscription: tim 81 | user: tim 82 | occurred_on: <%= 59.days.ago.to_date.to_s(:db) %> 83 | actor_name: Starting balance 84 | actor: tim_starting_balance 85 | check_number: ~ 86 | created_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 87 | updated_at: <%= (59.days.ago + 1.hour).utc.to_s(:db) %> 88 | -------------------------------------------------------------------------------- /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) 17 | amount = (amount.to_s.tr(",", "").to_f * 100).round 18 | elsif amount =~ /[.,]/ 19 | dollars, cents = amount.split(".") 20 | dollars = dollars.tr(",", "").to_i 21 | 22 | cents = cents[0,2] 23 | cents << "0" while cents.length < 2 24 | cents = cents.to_i 25 | 26 | amount = dollars * 100 + cents 27 | end 28 | 29 | super(amount) 30 | end 31 | 32 | def balance 33 | ending_balance 34 | end 35 | 36 | def balanced?(reload=false) 37 | unsettled_balance(reload).zero? 38 | end 39 | 40 | def settled_balance(reload=false) 41 | @settled_balance = nil if reload 42 | @settled_balance ||= account_items.to_a.sum(&:amount) 43 | end 44 | 45 | def unsettled_balance(reload=false) 46 | @unsettled_balance = nil if reload 47 | @unsettled_balance ||= starting_balance + settled_balance(reload) - ending_balance 48 | end 49 | 50 | def cleared=(ids) 51 | @ids_to_clear = ids 52 | @already_updated = false 53 | end 54 | 55 | protected 56 | 57 | def initialize_starting_balance 58 | self.starting_balance ||= account.statements.balanced.last.try(:ending_balance) || 0 59 | end 60 | 61 | def associate_account_items_with_self 62 | return if @already_updated 63 | @already_updated = true 64 | 65 | account_items.clear 66 | 67 | ids = connection.select_values(sanitize_sql([<<-SQL.squish, account_id, ids_to_clear])) 68 | SELECT ai.id 69 | FROM account_items ai 70 | WHERE ai.account_id = ? 71 | AND ai.id IN (?) 72 | SQL 73 | 74 | connection.update(sanitize_sql([<<-SQL.squish, id, ids])) 75 | UPDATE account_items 76 | SET statement_id = ? 77 | WHERE id IN (?) 78 | SQL 79 | 80 | account_items.reset 81 | 82 | if @ids_to_clear 83 | if balanced?(true) && !balanced_at 84 | update_attribute :balanced_at, Time.now.utc 85 | elsif !balanced? && balanced_at 86 | update_attribute :balanced_at, nil 87 | end 88 | end 89 | end 90 | 91 | private 92 | 93 | def sanitize_sql(sql) 94 | self.class.send(:sanitize_sql, sql) 95 | end 96 | 97 | def ids_to_clear 98 | @ids_to_clear || [] 99 | end 100 | 101 | end 102 | -------------------------------------------------------------------------------- /app/models/bucket.rb: -------------------------------------------------------------------------------- 1 | class Bucket < ActiveRecord::Base 2 | RECENT_WINDOW_SIZE = 10 3 | 4 | Temp = Struct.new(:id, :name, :role, :balance) 5 | 6 | belongs_to :account 7 | belongs_to :author, :class_name => "User", :foreign_key => "user_id" 8 | 9 | has_many :line_items 10 | 11 | attr_accessible :name, :role 12 | 13 | validates_presence_of :name 14 | validates_uniqueness_of :name, :scope => :account_id, :case_sensitive => false 15 | 16 | named_scope :filter, lambda { |filter| Bucket.options_for_filter(filter) } 17 | 18 | def self.options_for_filter(filter) 19 | return {} unless filter.any? 20 | 21 | conditions = [] 22 | parameters = [] 23 | 24 | if filter.from? 25 | conditions << "line_items.occurred_on >= ?" 26 | parameters << filter.from 27 | end 28 | 29 | if filter.to? 30 | conditions << "line_items.occurred_on <= ?" 31 | parameters << filter.to 32 | end 33 | 34 | if filter.by_type? 35 | roles = [] 36 | 37 | if filter.expenses? 38 | roles << 'payment_source' 39 | roles << 'transfer_from' 40 | roles << 'credit_options' 41 | end 42 | 43 | if filter.deposits? 44 | roles << 'deposit' 45 | roles << 'transfer_to' 46 | end 47 | 48 | if filter.reallocations? 49 | roles << 'primary' 50 | roles << 'aside' 51 | roles << 'credit_options' 52 | roles << 'reallocation_from' 53 | roles << 'reallocation_to' 54 | end 55 | 56 | conditions << "line_items.role IN (?)" 57 | parameters << roles.uniq 58 | end 59 | 60 | { :joins => "LEFT OUTER JOIN line_items ON line_items.bucket_id = buckets.id", 61 | :select => "buckets.*, SUM(line_items.amount) as computed_balance", 62 | :conditions => [conditions.join(" AND "), *parameters], 63 | :group => "buckets.id" } 64 | end 65 | 66 | def self.default 67 | Temp.new("r:default", "General", "default", 0) 68 | end 69 | 70 | def self.aside 71 | Temp.new("r:aside", "Aside", "aside", 0) 72 | end 73 | 74 | def self.template 75 | new :name => "Bucket name (e.g. Groceries)", 76 | :role => "aside | default | nil" 77 | end 78 | 79 | def self.recent(n=RECENT_WINDOW_SIZE) 80 | find(:all, :limit => n, :order => "updated_at DESC").sort_by(&:name) 81 | end 82 | 83 | def balance 84 | (self[:computed_balance] || self[:balance]).to_i 85 | end 86 | 87 | def assimilate(bucket) 88 | if bucket == self 89 | raise ArgumentError, "cannot assimilate self" 90 | end 91 | 92 | if bucket.account_id != account_id 93 | raise ArgumentError, "cannot assimilate bucket from different account" 94 | end 95 | 96 | old_id = bucket.id 97 | 98 | Bucket.transaction do 99 | LineItem.update_all(["bucket_id = ?", id], :bucket_id => old_id) 100 | update_attribute :balance, balance + bucket.balance 101 | bucket.destroy 102 | end 103 | end 104 | 105 | def to_xml(options={}) 106 | options[:only] = %w(name role) if new_record? 107 | super(options) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | if($F('account_role') == 'credit-card' && $F('account_limit').blank()) { 38 | $('account_name').activate(); 39 | alert('Please provide a limit for the account.'); 40 | return false; 41 | } 42 | 43 | var balance = Money.parse('current_balance', true); 44 | $('account_starting_balance_amount').value = balance; 45 | 46 | var limit = Money.parse('account_limit', true); 47 | $('account_limit').value = limit; 48 | 49 | return true; 50 | }, 51 | 52 | reset: function() { 53 | $('new_account_form').reset(); 54 | }, 55 | 56 | rename: function(url, name, token) { 57 | new_name = prompt("Enter the name for this account:", name); 58 | if(new_name && new_name != name) { 59 | params = encodeURIComponent("account[name]") + "=" + encodeURIComponent(new_name) + 60 | "&authenticity_token=" + encodeURIComponent(token); 61 | 62 | new Ajax.Request(url, { 63 | asynchronous:true, 64 | evalScripts:true, 65 | method:'put', 66 | parameters:params 67 | }); 68 | } 69 | }, 70 | 71 | adjustLimit: function(url, limit, token) { 72 | new_limit = prompt("Enter the new limit for this account:", Money.formatValue(limit)); 73 | new_limit = Money.parseValue(new_limit); 74 | while(new_limit == '') { 75 | new_limit = prompt("Cannot have have a blank limit. Please re-enter it:", Money.formatValue(limit)); 76 | } 77 | if(new_limit && new_limit != limit) { 78 | params = encodeURIComponent("account[limit]") + "=" + encodeURIComponent(new_limit) + 79 | "&authenticity_token=" + encodeURIComponent(token); 80 | 81 | new Ajax.Request(url, { 82 | asynchronous:true, 83 | evalScripts:true, 84 | method:'put', 85 | parameters:params, 86 | onSuccess: function(request) { 87 | window.location.reload(); 88 | } 89 | }); 90 | } 91 | }, 92 | 93 | showOrHideCreditLimit: function(value) { 94 | if (value == 'credit-card') { 95 | Accounts.showCreditLimit(); 96 | } else { 97 | Accounts.hideCreditLimit(); 98 | } 99 | }, 100 | 101 | showCreditLimit: function() { 102 | $('credit_limit_div').show(); 103 | }, 104 | 105 | hideCreditLimit: function() { 106 | $('credit_limit_div').hide(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /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 | has_many :actors 9 | 10 | has_many :events do 11 | def recent(n=0, options={}) 12 | size = (options[:size] || DEFAULT_PAGE_SIZE).to_i 13 | n = n.to_i 14 | 15 | joins = [] 16 | conditions = [] 17 | parameters = [] 18 | 19 | if options[:actor] 20 | joins << "LEFT JOIN actors ON actors.id = events.actor_id" 21 | conditions << "actors.sort_name = ?" 22 | parameters << Actor.normalize_name(options[:actor]) 23 | end 24 | 25 | records = find(:all, :joins => joins, 26 | :conditions => conditions.any? ? [conditions.join(" AND "), *parameters] : nil, 27 | :include => :account_items, 28 | :order => "events.created_at DESC", 29 | :limit => size + 1, 30 | :offset => n * size) 31 | 32 | [records.length > size, records[0,size]] 33 | end 34 | 35 | def prepare(attrs={}) 36 | event = build(:role => attrs[:role], :occurred_on => Date.today) 37 | 38 | case event.role 39 | when :reallocation 40 | event.actor_name = "Bucket reallocation" 41 | if attrs[:from] 42 | bucket = Bucket.find(attrs[:from]) 43 | account = @owner.accounts.find(bucket.account_id) 44 | event.line_items.build(:role => "primary", :account => account, :bucket => bucket) 45 | event.line_items.build(:role => "reallocate_from", :account => account, :bucket => account.buckets.default) 46 | elsif attrs[:to] 47 | bucket = Bucket.find(attrs[:to]) 48 | account = @owner.accounts.find(bucket.account_id) 49 | event.line_items.build(:role => "primary", :account => account, :bucket => bucket) 50 | event.line_items.build(:role => "reallocate_to", :account => account, :bucket => account.buckets.default) 51 | end 52 | end 53 | 54 | return event 55 | end 56 | end 57 | 58 | has_many :user_subscriptions 59 | has_many :users, :through => :user_subscriptions 60 | 61 | # removes everything from the subscription, without deleting the subscription 62 | def clean 63 | transaction do 64 | connection.delete "DELETE FROM line_items WHERE event_id IN (SELECT id FROM events WHERE subscription_id = #{id})" 65 | connection.delete "DELETE FROM account_items WHERE event_id IN (SELECT id FROM events WHERE subscription_id = #{id})" 66 | connection.delete "DELETE FROM tagged_items WHERE event_id IN (SELECT id FROM events WHERE subscription_id = #{id})" 67 | 68 | connection.delete "DELETE FROM buckets WHERE account_id IN (SELECT id FROM accounts WHERE subscription_id = #{id})" 69 | connection.delete "DELETE FROM statements WHERE account_id IN (SELECT id FROM accounts WHERE subscription_id = #{id})" 70 | 71 | connection.delete "DELETE FROM actors WHERE subscription_id = #{id}" 72 | connection.delete "DELETE FROM events WHERE subscription_id = #{id}" 73 | connection.delete "DELETE FROM accounts WHERE subscription_id = #{id}" 74 | connection.delete "DELETE FROM tags WHERE subscription_id = #{id}" 75 | end 76 | end 77 | 78 | # an optimized destroy to avoid costly dependency cascades 79 | def destroy 80 | transaction do 81 | clean 82 | connection.delete "DELETE FROM user_subscriptions WHERE subscription_id = #{id}" 83 | connection.delete "DELETE FROM subscriptions WHERE id = #{id}" 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /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 | 81 | test "ending_balance should not have truncation errors" do 82 | statements(:john).update_attribute :ending_balance, 2557.68 83 | assert statements(:john).ending_balance == 255768 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.js do 11 | json = events.to_json(eager_options(:root => "events", :include => { :tagged_items => { :only => [:amount, :id], :methods => :name }, :line_items => { :only => [:account_id, :bucket_id, :amount, :role], :methods => [] }})) 12 | 13 | render :update do |page| 14 | page << "Events.doneLoadingRecalledEvents(#{json})" 15 | end 16 | end 17 | format.xml do 18 | render :xml => events.to_xml(eager_options(:root => "events")) 19 | end 20 | end 21 | end 22 | 23 | def show 24 | respond_to do |format| 25 | format.js 26 | format.xml { render :xml => event.to_xml(eager_options) } 27 | end 28 | end 29 | 30 | def edit 31 | end 32 | 33 | def new 34 | @event = subscription.events.prepare(params) 35 | 36 | respond_to do |format| 37 | format.html 38 | format.xml { render :xml => event.to_xml(:include => [:line_items, :tagged_items]) } 39 | end 40 | end 41 | 42 | def create 43 | @event = subscription.events.create!(params[:event], :user => user) 44 | respond_to do |format| 45 | format.js 46 | format.xml do 47 | render :status => :created, :location => event_url(@event), 48 | :xml => @event.to_xml(:include => [:line_items, :tagged_items]) 49 | end 50 | end 51 | rescue ActiveRecord::RecordInvalid => error 52 | @event = error.record 53 | respond_to do |format| 54 | format.js 55 | format.xml { render :status => :unprocessable_entity, :xml => @event.errors } 56 | end 57 | end 58 | 59 | def update 60 | event.update_attributes!(params[:event]) 61 | respond_to do |format| 62 | format.js 63 | format.xml { render :xml => event.to_xml(:include => [:line_items, :tagged_items]) } 64 | end 65 | rescue ActiveRecord::RecordInvalid => error 66 | respond_to do |format| 67 | format.js 68 | format.xml { render :status => :unprocessable_entity, :xml => event.errors } 69 | end 70 | end 71 | 72 | def destroy 73 | event.destroy 74 | respond_to do |format| 75 | format.js 76 | format.xml { head :ok } 77 | end 78 | end 79 | 80 | protected 81 | 82 | attr_reader :event, :container, :account, :bucket, :tag, :events 83 | helper_method :event, :container, :account, :bucket 84 | 85 | def find_event 86 | @event = Event.find(params[:id]) 87 | @subscription = user.subscriptions.find(@event.subscription_id) 88 | end 89 | 90 | def find_container 91 | if params[:subscription_id] 92 | @container = find_subscription 93 | elsif params[:account_id] 94 | @container = @account = Account.find(params[:account_id]) 95 | @subscription = user.subscriptions.find(@account.subscription_id) 96 | elsif params[:bucket_id] 97 | @container = @bucket = Bucket.find(params[:bucket_id]) 98 | @subscription = user.subscriptions.find(@bucket.account.subscription_id) 99 | elsif params[:tag_id] 100 | @container = @tag = Tag.find(params[:tag_id]) 101 | @subscription = user.subscriptions.find(@tag.subscription_id) 102 | else 103 | raise ArgumentError, "no container specified for event listing" 104 | end 105 | end 106 | 107 | def find_events 108 | method = :page 109 | 110 | case container 111 | when Subscription then 112 | association = :events 113 | method = :recent 114 | when Account 115 | association = :account_items 116 | when Bucket 117 | association = :line_items 118 | when Tag 119 | association = :tagged_items 120 | else 121 | raise ArgumentError, "unsupported container type #{container.class}" 122 | end 123 | 124 | more_pages, list = container.send(association).send(method, params[:page], :size => params[:size], :actor => params[:actor]) 125 | unless list.first.is_a?(Event) 126 | list = list.map do |item| 127 | event = item.event 128 | event.amount = item.amount 129 | event 130 | end 131 | end 132 | 133 | @events = list 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/populator.rb: -------------------------------------------------------------------------------- 1 | class Populator 2 | def self.for(subscription) 3 | populator = new(subscription) 4 | yield populator 5 | populator.commit! 6 | end 7 | 8 | attr_reader :subscription 9 | 10 | def initialize(subscription) 11 | @subscription = subscription 12 | @accounts = [] 13 | @posts = [] 14 | end 15 | 16 | def account(name, role, starting_balance, balance_date, limit=nil) 17 | raise "you have to specify a limit for credit-card accounts" if role == "credit-card" && limit.nil? 18 | @accounts << { :name => name, :role => role, :limit => limit, 19 | :starting_balance => { :amount => starting_balance, :occurred_on => balance_date }} 20 | end 21 | 22 | def post(occurred_on, actor_name, default_amount=nil) 23 | returning Post.new(occurred_on, actor_name, default_amount) do |post| 24 | @posts << post 25 | end 26 | end 27 | 28 | def commit! 29 | Account.transaction do 30 | @accounts.each do |account| 31 | subscription.accounts.create!(account, :author => subscription.users.rand) 32 | end 33 | 34 | acct_cache = Hash.new { |h,k| h[k] = subscription.accounts.find_by_name(k) or raise IndexError, "key not found: #{k}" } 35 | 36 | @posts.each do |post| 37 | post.line_items.each do |item| 38 | if acct_name = item.delete(:account) 39 | acct = acct_cache[acct_name] 40 | item[:account_id] = acct.id 41 | end 42 | 43 | if bucket_name = item.delete(:bucket) 44 | if bucket_name =~ /^[rn]:/ 45 | item[:bucket_id] = bucket_name 46 | else 47 | item[:bucket_id] = "n:#{bucket_name}" 48 | end 49 | end 50 | end 51 | 52 | event_data = { :occurred_on => post.occurred_on, :actor_name => post.actor_name, 53 | :check_number => post.check_number, :memo => post.memo, 54 | :line_items => post.line_items, :tagged_items => post.tagged_items } 55 | 56 | subscription.events.create!(event_data, :user => subscription.users.rand) 57 | end 58 | end 59 | end 60 | 61 | class Post 62 | attr_reader :occurred_on, :actor_name, :check_number 63 | attr_reader :default_amount 64 | attr_reader :line_items, :tagged_items 65 | 66 | def initialize(occurred_on, actor_name, default_amount) 67 | @occurred_on = occurred_on 68 | @actor_name = actor_name 69 | @default_amount = default_amount 70 | @line_items = [] 71 | @tagged_items = [] 72 | @check_number = @memo = nil 73 | end 74 | 75 | def check(number) 76 | @check_number = number 77 | self 78 | end 79 | 80 | def memo(text=nil) 81 | if text 82 | @memo = text 83 | self 84 | else 85 | @memo 86 | end 87 | end 88 | 89 | def copy(post) 90 | @memo = post.memo && post.memo.dup 91 | @default_amount = post.default_amount 92 | @line_items = Marshal.load(Marshal.dump(post.line_items)) 93 | @tagged_items = Marshal.load(Marshal.dump(post.tagged_items)) 94 | self 95 | end 96 | 97 | def source(account, bucket) 98 | make_line_items(account, "payment_source", bucket, true) 99 | self 100 | end 101 | 102 | def repay(account, bucket) 103 | make_line_items(account, "credit_options", bucket, true) 104 | line_items << { :role => "aside", :account => account, :bucket_id => "r:aside", :amount => default_amount } 105 | self 106 | end 107 | 108 | def deposit(account, bucket) 109 | make_line_items(account, "deposit", bucket, false) 110 | self 111 | end 112 | 113 | def from(account, bucket) 114 | make_line_items(account, "transfer_from", bucket, true) 115 | self 116 | end 117 | 118 | def to(account, bucket) 119 | make_line_items(account, "transfer_to", bucket, false) 120 | self 121 | end 122 | 123 | def reallocate(account, direction, primary, buckets) 124 | balance = make_line_items(account, "reallocate_#{direction}", buckets, direction == :to) 125 | line_items << { :role => "primary", :account => account, :bucket => primary, 126 | :amount => (direction == :from ? -1 : 1) * balance } 127 | self 128 | end 129 | 130 | def tag(*tags) 131 | partials = tags.last.is_a?(Hash) ? tags.pop : {} 132 | 133 | tags.each do |name| 134 | tagged_items << { :tag_id => "n:#{name}", :amount => default_amount } 135 | end 136 | 137 | partials.each do |name, amount| 138 | tagged_items << { :tag_id => "n:#{name}", :amount => amount } 139 | end 140 | 141 | self 142 | end 143 | 144 | private 145 | 146 | def each_item(items) 147 | sum = 0 148 | 149 | Array(items).each do |item| 150 | bucket, amount = Array(item) 151 | amount ||= default_amount 152 | sum += amount 153 | yield bucket, amount 154 | end 155 | 156 | @default_amount ||= sum 157 | return sum 158 | end 159 | 160 | def make_line_items(account, role, items, expense) 161 | each_item(items) do |name, amount| 162 | line_items << { :role => role, :account => account, :bucket => name, 163 | :amount => (expense ? -1 : 1) * amount } 164 | end 165 | end 166 | end 167 | end 168 | 169 | -------------------------------------------------------------------------------- /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 either a gem, or under version control somewhere, and the gem sources and 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 | vendor/libs/tzinfo: 45 | :type: gem 46 | :version: 0.3.18 47 | 48 | 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. 49 | 50 | 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: 51 | 52 | cap local externals:setup 53 | 54 | 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). 55 | 56 | 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. 57 | 58 | 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. 59 | 60 | == Tips 61 | 62 | 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. 63 | 64 | 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. 65 | 66 | == License 67 | 68 | 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. 69 | -------------------------------------------------------------------------------- /app/models/account.rb: -------------------------------------------------------------------------------- 1 | class Account < ActiveRecord::Base 2 | DEFAULT_BUCKET_NAME = "General" 3 | 4 | # When should the levels of credit cards be reached (in %) 5 | DEFAULT_LIMIT_VALUES = { 6 | :critical => 100, 7 | :high => 80, 8 | :medium => 30, 9 | :low => 0 10 | } 11 | 12 | belongs_to :subscription 13 | belongs_to :author, :class_name => "User", :foreign_key => "user_id" 14 | 15 | attr_accessor :starting_balance 16 | attr_accessible :name, :role, :limit, :starting_balance 17 | 18 | validates_presence_of :name 19 | validates_presence_of :limit, :if => :credit_card? 20 | validates_uniqueness_of :name, :scope => :subscription_id, :case_sensitive => false 21 | 22 | has_many :buckets do 23 | def for_role(role, user) 24 | role = role.downcase 25 | find_by_role(role) || create({:name => role.capitalize, :role => role}, :author => user) 26 | end 27 | 28 | def sorted 29 | sort_by(&:name) 30 | end 31 | 32 | def default 33 | detect { |bucket| bucket.role == "default" } 34 | end 35 | 36 | def with_defaults 37 | buckets = to_a.dup 38 | buckets << Bucket.default unless buckets.any? { |bucket| bucket.role == "default" } 39 | buckets << Bucket.aside unless buckets.any? { |bucket| bucket.role == "aside" } 40 | return buckets 41 | end 42 | end 43 | 44 | has_many :line_items 45 | has_many :statements 46 | 47 | has_many :account_items, :extend => CategorizedItems 48 | 49 | after_create :create_default_buckets, :set_starting_balance 50 | 51 | def self.template 52 | new :name => "Account name (e.g. Checking)", :role => "checking | credit-card | nil", 53 | :starting_balance => { :amount => 0, :occurred_on => Date.today } 54 | end 55 | 56 | def credit_card? 57 | role == 'credit-card' 58 | end 59 | 60 | def checking? 61 | role == 'checking' 62 | end 63 | 64 | def available_balance 65 | @available_balance ||= balance - unavailable_balance 66 | end 67 | 68 | def unavailable_balance 69 | @unavailable_balance ||= begin 70 | aside = buckets.detect { |bucket| bucket.role == 'aside' } 71 | aside && aside.balance > 0 ? aside.balance : 0 72 | end 73 | end 74 | 75 | def destroy 76 | transaction do 77 | cleanup_account_items 78 | cleanup_line_items 79 | cleanup_buckets 80 | cleanup_tagged_items 81 | cleanup_events 82 | 83 | Account.delete(id) 84 | end 85 | end 86 | 87 | def to_xml(options={}) 88 | if new_record? 89 | options[:only] = %w(name role) 90 | options[:procs] = Proc.new do |opts| 91 | starting_balance.to_xml(opts.merge(:root => "starting-balance")) 92 | end 93 | end 94 | 95 | super(options) 96 | end 97 | 98 | protected 99 | 100 | def create_default_buckets 101 | buckets.create({:name => DEFAULT_BUCKET_NAME, :role => "default"}, :author => author) 102 | end 103 | 104 | def set_starting_balance 105 | if starting_balance && !starting_balance[:amount].to_i.zero? 106 | amount = starting_balance[:amount].to_i 107 | role = amount > 0 ? "deposit" : "payment_source" 108 | subscription.events.create({:occurred_on => starting_balance[:occurred_on], 109 | :actor_name => "Starting balance", 110 | :line_items => [{:account_id => id, :bucket_id => buckets.default.id, 111 | :amount => amount, :role => role}] 112 | }, :user => author) 113 | reload # make sure the balance is set correctly 114 | end 115 | end 116 | 117 | private 118 | 119 | def cleanup_line_items 120 | LineItem.delete_all :account_id => id 121 | end 122 | 123 | def cleanup_account_items 124 | items = account_items.find(:all, :include => { :event => [:line_items, :account_items] }) 125 | 126 | items.each do |item| 127 | event = item.event 128 | next unless event.account_items.length > 1 129 | 130 | case event.role 131 | when :transfer then 132 | # if half of a transfer is being deleted, convert the other half to either a deposit 133 | # or an expense, depending on whether the half being deleted is the negative or 134 | # positive half. 135 | event.line_items.update_all(:role => (item.amount < 0 ? 'deposit' : 'payment_source')) 136 | 137 | when :expense then 138 | # if the credit_options account is being deleted, we don't need to do anything, 139 | # but if the payment_source account is being deleted, then we need to convert the 140 | # credit_options items to a bucket reallocation. 141 | if event.account_for(:payment_source) == self 142 | event.line_items.update_all({:role => 'primary'}, :role => 'aside') 143 | event.line_items.update_all({:role => 'reallocate_to'}, :role => 'credit_options') 144 | end 145 | end 146 | end 147 | 148 | AccountItem.delete_all :account_id => id 149 | end 150 | 151 | def cleanup_buckets 152 | Bucket.delete_all :account_id => id 153 | end 154 | 155 | def cleanup_tagged_items 156 | tagged_items = TaggedItem.find_by_sql(<<-SQL.squish) 157 | SELECT t.* FROM tagged_items t LEFT JOIN events e ON t.event_id = e.id 158 | WHERE e.subscription_id = #{subscription_id} 159 | AND NOT EXISTS ( 160 | SELECT * FROM account_items a WHERE a.event_id = e.id) 161 | SQL 162 | 163 | tagged_items.each { |item| item.destroy } 164 | end 165 | 166 | def cleanup_events 167 | connection.delete(<<-SQL.squish) 168 | DELETE FROM events 169 | WHERE subscription_id = #{subscription_id} 170 | AND NOT EXISTS ( 171 | SELECT * FROM account_items a WHERE a.event_id = events.id) 172 | SQL 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /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 "index should load account and subscription and render page" do 12 | get :index, :account_id => accounts(:john_checking).id 13 | assert_response :success 14 | assert_template "buckets/index" 15 | assert_equal subscriptions(:john), assigns(:subscription) 16 | assert_equal accounts(:john_checking), assigns(:account) 17 | assert_equal accounts(:john_checking).buckets.length, assigns(:buckets).length 18 | assert !assigns(:filter).any? 19 | end 20 | 21 | test "index with filter options should set filter and return only matching buckets" do 22 | get :index, :account_id => accounts(:john_checking).id, :expenses => true 23 | assert_response :success 24 | assert_template "buckets/index" 25 | assert assigns(:filter).any? 26 | assert_equal %w(Aside Dining), assigns(:buckets).map(&:name).sort 27 | end 28 | 29 | test "show should 404 when when user without permissions requests page" do 30 | get :show, :id => buckets(:tim_checking_general).id 31 | assert_response :missing 32 | end 33 | 34 | test "show should load bucket, account, and subscription and render page" do 35 | get :show, :id => buckets(:john_checking_dining).id 36 | assert_response :success 37 | assert_template "buckets/show" 38 | assert_equal subscriptions(:john), assigns(:subscription) 39 | assert_equal accounts(:john_checking), assigns(:account) 40 | assert_equal buckets(:john_checking_dining), assigns(:bucket) 41 | end 42 | 43 | test "update should 404 when user without permissions requests page" do 44 | xhr :put, :update, :id => buckets(:tim_checking_general).id, :bucket => { :name => "Hi!" } 45 | assert_response :missing 46 | assert_equal "General", buckets(:tim_checking_general, :reload).name 47 | end 48 | 49 | test "update should change bucket name and render javascript" do 50 | xhr :put, :update, :id => buckets(:john_checking_general).id, :bucket => { :name => "Hi!" } 51 | assert_response :success 52 | assert_template "buckets/update.js.rjs" 53 | assert_equal subscriptions(:john), assigns(:subscription) 54 | assert_equal accounts(:john_checking), assigns(:account) 55 | assert_equal buckets(:john_checking_general), assigns(:bucket) 56 | assert_equal "Hi!", buckets(:john_checking_general, :reload).name 57 | end 58 | 59 | test "destroy without receiver_id should 404" do 60 | assert_no_difference "Bucket.count" do 61 | delete :destroy, :id => buckets(:john_checking_dining).id 62 | end 63 | assert_response :missing 64 | end 65 | 66 | test "destroy should assimilate line items and destroy bucket" do 67 | assert_difference "Bucket.count", -1 do 68 | delete :destroy, :id => buckets(:john_checking_dining).id, 69 | :receiver_id => buckets(:john_checking_groceries).id 70 | end 71 | assert_redirected_to(buckets(:john_checking_groceries)) 72 | assert !Bucket.exists?(buckets(:john_checking_dining).id) 73 | assert buckets(:john_checking_groceries), line_items(:john_lunch_checking_dining).bucket 74 | end 75 | 76 | # == API tests ======================================================================== 77 | 78 | test "index via API should return bucket list for account" do 79 | get :index, :account_id => accounts(:john_checking).id, :format => "xml" 80 | assert_response :success 81 | xml = Hash.from_xml(@response.body) 82 | assert_equal accounts(:john_checking).buckets.length, xml["buckets"].length 83 | end 84 | 85 | test "show via API should return bucket record" do 86 | get :show, :id => buckets(:john_checking_dining).id, :format => "xml" 87 | assert_response :success 88 | xml = Hash.from_xml(@response.body) 89 | assert_equal buckets(:john_checking_dining).id, xml["bucket"]["id"] 90 | end 91 | 92 | test "new via API should return a template XML response" do 93 | get :new, :account_id => accounts(:john_checking).id, :format => "xml" 94 | assert_response :success 95 | xml = Hash.from_xml(@response.body) 96 | assert xml["bucket"] 97 | assert !xml["bucket"]["id"] 98 | end 99 | 100 | test "create via API should return 422 with error messages when validations fail" do 101 | post :create, 102 | :account_id => accounts(:john_checking).id, 103 | :bucket => { :name => "Dining", :role => "" }, 104 | :format => "xml" 105 | assert_response :unprocessable_entity 106 | xml = Hash.from_xml(@response.body) 107 | assert xml["errors"].any? 108 | end 109 | 110 | test "create via API should create record and respond with 201" do 111 | assert_difference "accounts(:john_checking).buckets.count" do 112 | post :create, 113 | :account_id => accounts(:john_checking).id, 114 | :bucket => { :name => "Utilities", :role => "" }, 115 | :format => "xml" 116 | assert_response :created 117 | assert @response.headers["Location"] 118 | end 119 | end 120 | 121 | test "update via API should update record and respond with 200" do 122 | put :update, :id => buckets(:john_checking_dining).id, :bucket => { :name => "Hi!" }, :format => "xml" 123 | assert_response :success 124 | xml = Hash.from_xml(@response.body) 125 | assert_equal "Hi!", xml["bucket"]["name"] 126 | end 127 | 128 | test "update via API with validation errors should respond with 422" do 129 | put :update, :id => buckets(:john_checking_dining).id, :bucket => { :name => "Groceries" }, :format => "xml" 130 | assert_response :unprocessable_entity 131 | xml = Hash.from_xml(@response.body) 132 | assert xml["errors"].any? 133 | end 134 | 135 | test "destroy via API should remove record and respond with 200" do 136 | assert_difference "Bucket.count", -1 do 137 | delete :destroy, :id => buckets(:john_checking_dining).id, 138 | :receiver_id => buckets(:john_checking_groceries).id, 139 | :format => "xml" 140 | assert_response :success 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------